diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 28b30cb9e3..031784429c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -1,10 +1,8 @@ package com.tangem.tap.di.domain -import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase -import com.tangem.domain.markets.GetTokenMarketInfoUseCase -import com.tangem.domain.markets.GetTokenPriceChartUseCase -import com.tangem.domain.markets.GetTokenQuotesUseCase +import com.tangem.domain.markets.* import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.tokens.repository.QuotesRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -37,7 +35,13 @@ object MarketsDomainModule { @Provides @Singleton - fun provideGetTokenQuotesUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenQuotesUseCase { - return GetTokenQuotesUseCase(marketsTokenRepository = marketsTokenRepository) + fun provideTokenFullQuotesUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenFullQuotesUseCase { + return GetTokenFullQuotesUseCase(marketsTokenRepository = marketsTokenRepository) + } + + @Provides + @Singleton + fun provideGetTokenQuotesUseCase(quotesRepository: QuotesRepository): GetTokenQuotesUseCase { + return GetTokenQuotesUseCase(quotesRepository = quotesRepository) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 6815e3f6c9..c2b8cf249c 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -11,6 +11,7 @@ import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.managetokens.ManageTokensToggles import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter import com.tangem.features.send.api.navigation.SendRouter @@ -50,6 +51,7 @@ internal class ChildFactory @Inject constructor( private val walletSettingsComponentFactory: WalletSettingsComponent.Factory, private val disclaimerComponentFactory: DisclaimerComponent.Factory, private val manageTokensComponentFactory: ManageTokensComponent.Factory, + private val marketsTokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, private val sendRouter: SendRouter, private val tokenDetailsRouter: TokenDetailsRouter, private val walletRouter: WalletRouter, @@ -185,6 +187,16 @@ internal class ChildFactory @Inject constructor( componentFactory = walletSettingsComponentFactory, ) } + is AppRoute.MarketsTokenDetails -> { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = MarketsTokenDetailsComponent.Params( + token = route.token, + appCurrency = route.appCurrency, + ), + componentFactory = marketsTokenDetailsComponentFactory, + ) + } } } diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index a492ede7aa..71933a08b2 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) implementation(projects.domain.staking.models) + implementation(projects.domain.markets.models) + implementation(projects.domain.appCurrency.models) /* Libs - Other */ api(deps.kotlin.serialization) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 4f53e068c8..4093b6c8a0 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -5,6 +5,8 @@ import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle import com.tangem.common.routing.entity.SerializableIntent import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParam import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrency @@ -262,4 +264,10 @@ sealed class AppRoute(val path: String) : Route { data class WalletSettings( val userWalletId: UserWalletId, ) : AppRoute(path = "/wallet_settings/${userWalletId.stringValue}") + + @Serializable + data class MarketsTokenDetails( + val token: TokenMarketParam, + val appCurrency: AppCurrency, + ) : AppRoute(path = "/markets_token_details/${token.id}") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt new file mode 100644 index 0000000000..1bddd8572e --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/utils/RouterProxy.kt @@ -0,0 +1,51 @@ +package com.tangem.common.routing.utils + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.core.decompose.navigation.Route +import com.tangem.core.decompose.navigation.Router +import kotlin.reflect.KClass + +/** + * Temporary solution to convert [AppRouter] to [Router]. + + * (through manual ComponentContext creation). + * + * **Will be removed when all screens will be migrated to Decompose.** + * + * @return [Router] that wraps [AppRouter]. + */ +fun AppRouter.asRouter(): Router { + return RouterProxy(appRouter = this) +} + +private class RouterProxy( + private val appRouter: AppRouter, +) : Router { + override fun push(route: Route, onComplete: (isSuccess: Boolean) -> Unit) { + (route as? AppRoute)?.let { + appRouter.push(it, onComplete) + } + } + + override fun replaceAll(vararg routes: Route, onComplete: (isSuccess: Boolean) -> Unit) { + routes.filterIsInstance().let { + appRouter.replaceAll(*it.toTypedArray(), onComplete = onComplete) + } + } + + override fun pop(onComplete: (isSuccess: Boolean) -> Unit) { + appRouter.pop(onComplete) + } + + override fun popTo(route: Route, onComplete: (isSuccess: Boolean) -> Unit) { + (route as? AppRoute)?.let { + appRouter.popTo(route, onComplete) + } + } + + @Suppress("UNCHECKED_CAST") + override fun popTo(routeClass: KClass, onComplete: (isSuccess: Boolean) -> Unit) { + appRouter.popTo(routeClass as KClass, onComplete) + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt index e51a8f72a6..8eeec1a691 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt @@ -22,6 +22,11 @@ class PriceAndTimePointValuesConverter( private val formatYValuesCache = mutableMapOf() private val formatXValuesCache = mutableMapOf() + private data class Point( + val x: BigDecimal, + val y: BigDecimal, + ) + override fun convert(data: MarketChartData.Data): MarketChartRawData { formatYValuesCache.clear() formatXValuesCache.clear() @@ -33,8 +38,10 @@ class PriceAndTimePointValuesConverter( ) minMaxCache = cache - val normY = data.y.normalizeToDouble(min = cache.minY, max = cache.maxY) - val normX = data.x.normalizeTime(min = cache.minX, max = cache.maxX) + val points = data.x.zip(data.y) { x, y -> Point(x, y) }.sortedBy { it.x } + + val normY = points.map { it.y }.normalizeToDouble(min = cache.minY, max = cache.maxY) + val normX = points.map { it.x }.normalizeTime(min = cache.minX, max = cache.maxX) return if (normX.size > MAX_POINTS) { LTThreeBuckets diff --git a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt index 586779ec4a..6ffebce9fe 100644 --- a/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt +++ b/core/decompose/src/main/kotlin/com/tangem/core/decompose/context/DefaultAppComponentContext.kt @@ -19,6 +19,7 @@ class DefaultAppComponentContext( messageHandler: UiMessageHandler, override val dispatchers: CoroutineDispatcherProvider, override val hiltComponentBuilder: DecomposeComponent.Builder, + private val replaceRouter: Router? = null, ) : AppComponentContext, ComponentContext by componentContext { override val tags: HashMap = HashMap() @@ -31,5 +32,5 @@ class DefaultAppComponentContext( get() = instanceKeeper.getOrCreate { DefaultAppNavigationProvider() } override val router: Router - get() = instanceKeeper.getOrCreate { DefaultRouter(navigationProvider) } + get() = replaceRouter ?: instanceKeeper.getOrCreate { DefaultRouter(navigationProvider) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt index de78fc1368..26aec82eab 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt @@ -1,6 +1,21 @@ package com.tangem.core.ui.components.marketprice +import java.math.BigDecimal +import java.math.RoundingMode + /** Price changing type */ enum class PriceChangeType { UP, DOWN, NEUTRAL, + ; + + companion object { + @Suppress("MagicNumber") + fun fromBigDecimal(priceChangePercent: BigDecimal): PriceChangeType { + return when { + priceChangePercent < BigDecimal.ZERO -> DOWN + priceChangePercent.setScale(4, RoundingMode.HALF_UP) > BigDecimal.ZERO -> UP + else -> NEUTRAL + } + } + } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index de9edf3b4b..fa6e9a355e 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -108,6 +108,22 @@ internal class DefaultMarketsTokenRepository( return TokenChartConverter.convert(interval, response.getOrThrow()) } + override suspend fun getChartPreview( + fiatCurrencyCode: String, + interval: PriceChangeInterval, + tokenId: String, + ): TokenChart { + val response = marketsApi.getCoinsListCharts( + coinIds = tokenId, + currency = fiatCurrencyCode, + interval = interval.toRequestParam(), + ) + + val chart = response.getOrThrow()[tokenId] ?: error("No chart preview data for token $tokenId") + + return TokenChartConverter.convert(interval, chart) + } + override suspend fun getTokenInfo( fiatCurrencyCode: String, tokenId: String, diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 885998c263..c0461ee9ab 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -11,12 +11,15 @@ android { dependencies { + /* Domain */ api(projects.domain.appCurrency.models) api(projects.domain.core) api(projects.core.pagination) api(projects.domain.markets.models) - - implementation(deps.kotlin.serialization) implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + + /* Utils */ + implementation(deps.kotlin.serialization) implementation(projects.core.utils) } \ No newline at end of file diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/TokenMarketSerializable.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketParam.kt similarity index 60% rename from features/markets/api/src/main/kotlin/com/tangem/features/markets/details/TokenMarketSerializable.kt rename to domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketParam.kt index 57a83d4462..6c9bd92066 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/TokenMarketSerializable.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketParam.kt @@ -1,35 +1,32 @@ -package com.tangem.features.markets.details +package com.tangem.domain.markets import com.tangem.domain.core.serialization.SerializedBigDecimal -import com.tangem.domain.markets.TokenMarket import kotlinx.serialization.Serializable @Serializable -data class TokenMarketSerializable( +data class TokenMarketParam( val id: String, val name: String, val symbol: String, - val marketCap: SerializedBigDecimal?, val tokenQuotes: Quotes, - val imageUrl: String, + val imageUrl: String?, ) { @Serializable data class Quotes( val currentPrice: SerializedBigDecimal, val h24Percent: SerializedBigDecimal, - val weekPercent: SerializedBigDecimal, - val monthPercent: SerializedBigDecimal, + val weekPercent: SerializedBigDecimal?, + val monthPercent: SerializedBigDecimal?, ) } -fun TokenMarket.toSerializable(): TokenMarketSerializable { - return TokenMarketSerializable( +fun TokenMarket.toSerializableParam(): TokenMarketParam { + return TokenMarketParam( id = id, name = name, symbol = symbol, - marketCap = marketCap, - tokenQuotes = TokenMarketSerializable.Quotes( + tokenQuotes = TokenMarketParam.Quotes( currentPrice = tokenQuotesShort.currentPrice, h24Percent = tokenQuotesShort.h24ChangePercent, weekPercent = tokenQuotesShort.weekChangePercent, diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenFullQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenFullQuotesUseCase.kt new file mode 100644 index 0000000000..a755057a98 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenFullQuotesUseCase.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.repositories.MarketsTokenRepository + +class GetTokenFullQuotesUseCase( + private val marketsTokenRepository: MarketsTokenRepository, +) { + suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { + return Either.catch { + marketsTokenRepository.getTokenQuotes( + fiatCurrencyCode = appCurrency.code, + tokenId = tokenId, + ) + }.mapLeft {} + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt index c686ff3874..feeeadab56 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt @@ -12,13 +12,22 @@ class GetTokenPriceChartUseCase( appCurrency: AppCurrency, interval: PriceChangeInterval, tokenId: String, + preview: Boolean, ): Either { return Either.catch { - marketsTokenRepository.getChart( - fiatCurrencyCode = appCurrency.code, - interval = interval, - tokenId = tokenId, - ) + if (preview) { + marketsTokenRepository.getChartPreview( + fiatCurrencyCode = appCurrency.code, + interval = interval, + tokenId = tokenId, + ) + } else { + marketsTokenRepository.getChart( + fiatCurrencyCode = appCurrency.code, + interval = interval, + tokenId = tokenId, + ) + } }.mapLeft {} } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt index 6477010d57..059c80ac82 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt @@ -1,19 +1,26 @@ package com.tangem.domain.markets import arrow.core.Either -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.tokens.repository.QuotesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +@Suppress("UnusedPrivateMember") class GetTokenQuotesUseCase( - private val marketsTokenRepository: MarketsTokenRepository, + private val quotesRepository: QuotesRepository, ) { - - suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { - return Either.catch { - marketsTokenRepository.getTokenQuotes( - fiatCurrencyCode = appCurrency.code, - tokenId = tokenId, - ) - }.mapLeft {} + operator fun invoke(tokenId: String, interval: PriceChangeInterval): Flow> { + return flowOf( + Either.catch { + Quote( + rawCurrencyId = "USD", + fiatRate = 100.toBigDecimal(), // mock + priceChange = 10.toBigDecimal(), // mock + ) + }.mapLeft {}, + ) + // TODO implement quotes fetching from repository [REDACTED_TASK_KEY] + // quotesRepository.getQuotesUpdates() } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index 5c80144aec..e96aa4525c 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -12,6 +12,8 @@ interface MarketsTokenRepository { suspend fun getChart(fiatCurrencyCode: String, interval: PriceChangeInterval, tokenId: String): TokenChart + suspend fun getChartPreview(fiatCurrencyCode: String, interval: PriceChangeInterval, tokenId: String): TokenChart + suspend fun getTokenInfo(fiatCurrencyCode: String, tokenId: String, languageCode: String): TokenMarketInfo suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String): TokenQuotes diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/MarketsTokenDetailsComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/MarketsTokenDetailsComponent.kt index c22cdf5ccd..4698b42133 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/MarketsTokenDetailsComponent.kt +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/details/MarketsTokenDetailsComponent.kt @@ -5,17 +5,19 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp -import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParam import com.tangem.features.markets.entry.BottomSheetState import kotlinx.serialization.Serializable @Stable -interface MarketsTokenDetailsComponent { +interface MarketsTokenDetailsComponent : ComposableContentComponent { @Serializable data class Params( - val token: TokenMarketSerializable, + val token: TokenMarketParam, val appCurrency: AppCurrency, ) @@ -26,7 +28,5 @@ interface MarketsTokenDetailsComponent { modifier: Modifier, ) - interface Factory { - fun create(context: AppComponentContext, params: Params, onBack: () -> Unit): MarketsTokenDetailsComponent - } + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/token/block/TokenMarketBlockComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/token/block/TokenMarketBlockComponent.kt index 270528b0c7..dbd1b4f877 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/token/block/TokenMarketBlockComponent.kt +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/token/block/TokenMarketBlockComponent.kt @@ -3,7 +3,6 @@ package com.tangem.features.markets.token.block import androidx.compose.runtime.Stable import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.tokens.model.CryptoCurrency import kotlinx.serialization.Serializable @Stable @@ -11,7 +10,10 @@ interface TokenMarketBlockComponent : ComposableContentComponent { @Serializable data class Params( - val cryptoCurrency: CryptoCurrency, + val tokenId: String, + val tokenName: String, + val tokenSymbol: String, + val tokenImageUrl: String?, ) interface Factory { diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index aca008e2bd..320f41fbc3 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -51,4 +51,5 @@ dependencies { /* Common */ implementation(projects.common.ui) implementation(projects.common.uiCharts) + implementation(projects.common.routing) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt index 9c389d9a50..ed098bec45 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -8,11 +8,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.markets.entry.BottomSheetState +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.MarketsTokenDetailsComponent.Params import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel import com.tangem.features.markets.details.impl.model.state.TokenNetworksState import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent +import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.portfolio.api.MarketsPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -23,8 +26,7 @@ import kotlinx.coroutines.launch @Stable internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: MarketsTokenDetailsComponent.Params, - @Assisted private val onBack: () -> Unit, + @Assisted params: Params, portfolioComponentFactory: MarketsPortfolioComponent.Factory, ) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent { @@ -64,26 +66,50 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( val bsState by bottomSheetState LaunchedEffect(bsState) { - model.containerBottomSheetState.value = bsState + model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED } MarketsTokenDetailsContent( - state = state, - onBackClick = onBack, - onHeaderSizeChange = onHeaderSizeChange, - portfolioBlock = { modifier -> - portfolioComponent.Content(modifier) - }, modifier = modifier, + backgroundColor = LocalMainBottomSheetColor.current.value, + addTopBarStatusBarPadding = false, + state = state, + onBackClick = ::navigateBack, + onHeaderSizeChange = onHeaderSizeChange, + portfolioBlock = { blockModifier -> + portfolioComponent.Content(blockModifier) + }, ) } + @Composable + override fun Content(modifier: Modifier) { + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + + val state by model.state.collectAsStateWithLifecycle() + + MarketsTokenDetailsContent( + modifier = modifier, + backgroundColor = TangemTheme.colors.background.tertiary, + addTopBarStatusBarPadding = true, + state = state, + onBackClick = ::navigateBack, + onHeaderSizeChange = {}, + portfolioBlock = { blockModifier -> + portfolioComponent.Content(blockModifier) + }, + ) + } + + private fun navigateBack() = router.pop() + @AssistedFactory interface Factory : MarketsTokenDetailsComponent.Factory { - override fun create( - context: AppComponentContext, - params: MarketsTokenDetailsComponent.Params, - onBack: () -> Unit, - ): DefaultMarketsTokenDetailsComponent + override fun create(context: AppComponentContext, params: Params): DefaultMarketsTokenDetailsComponent } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt index 11baa49129..d2f3aa34f5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -18,7 +18,6 @@ import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* -import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.details.MarketsTokenDetailsComponent import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter @@ -52,7 +51,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, - private val getTokenQuotesUseCase: GetTokenQuotesUseCase, + private val getTokenFullQuotesUseCase: GetTokenFullQuotesUseCase, private val urlOpener: UrlOpener, ) : Model() { @@ -118,7 +117,6 @@ internal class MarketsTokenDetailsModel @Inject constructor( private var lastUpdatedTimestamp: Long = DateTime.now().millis - val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) val isVisibleOnScreen = MutableStateFlow(false) val networksState = MutableStateFlow(TokenNetworksState.Loading) @@ -135,11 +133,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( percent = params.token.tokenQuotes.h24Percent, useAbsoluteValue = true, ), - priceChangeType = if (params.token.tokenQuotes.h24Percent < BigDecimal.ZERO) { - PriceChangeType.DOWN - } else { - PriceChangeType.UP - }, + priceChangeType = params.token.tokenQuotes.h24Percent.percentChangeType(), iconUrl = params.token.imageUrl, chartState = MarketsTokenDetailsUM.ChartState( dataProducer = chartDataProducer, @@ -184,7 +178,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private fun loadQuotes() { modelScope.launch { - val result = getTokenQuotesUseCase( + val result = getTokenFullQuotesUseCase( tokenId = params.token.id, appCurrency = currentAppCurrency.value, ) @@ -213,6 +207,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( appCurrency = currentAppCurrency.value, interval = interval, tokenId = params.token.id, + preview = false, ) state.update { @@ -246,6 +241,11 @@ internal class MarketsTokenDetailsModel @Inject constructor( chartState = it.chartState.copy( status = MarketsTokenDetailsUM.ChartState.Status.DATA, ), + body = if (it.body is MarketsTokenDetailsUM.Body.Nothing) { + MarketsTokenDetailsUM.Body.Error(onLoadRetryClick = ::onLoadRetryClicked) + } else { + it.body + }, ) } }.onLeft { @@ -488,7 +488,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( while (true) { delay(timeMillis) // Update quotes only when the container bottom sheet is in the expanded state - containerBottomSheetState.first { it == BottomSheetState.EXPANDED } + // and is visible on the screen isVisibleOnScreen.first { it } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt index f164289fe6..c4cf1f6e3f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt @@ -48,11 +48,13 @@ internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): Bi } } +@Suppress("MagicNumber") internal fun BigDecimal?.percentChangeType(): PriceChangeType { + val scaled = this?.setScale(4, RoundingMode.HALF_UP) return when { - this == null -> PriceChangeType.NEUTRAL - this > BigDecimal.ZERO -> PriceChangeType.UP - this < BigDecimal.ZERO -> PriceChangeType.DOWN + scaled == null -> PriceChangeType.NEUTRAL + scaled > BigDecimal.ZERO -> PriceChangeType.UP + scaled < BigDecimal.ZERO -> PriceChangeType.DOWN else -> PriceChangeType.NEUTRAL } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt index 001c81cf27..e801ed3cb0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview @@ -33,7 +34,6 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.disableNestedScroll @@ -45,10 +45,12 @@ import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.impl.R import kotlinx.collections.immutable.persistentListOf -@Suppress("UnusedPrivateMember") +@Suppress("LongParameterList") @Composable internal fun MarketsTokenDetailsContent( state: MarketsTokenDetailsUM, + backgroundColor: Color, + addTopBarStatusBarPadding: Boolean, onBackClick: () -> Unit, onHeaderSizeChange: (Dp) -> Unit, portfolioBlock: @Composable (Modifier) -> Unit, @@ -56,41 +58,46 @@ internal fun MarketsTokenDetailsContent( ) { Content( modifier = modifier, + backgroundColor = backgroundColor, state = state, onBackClick = onBackClick, onHeaderSizeChange = onHeaderSizeChange, portfolioBlock = portfolioBlock, + addTopBarStatusBarInsets = addTopBarStatusBarPadding, ) InfoBottomSheet(config = state.infoBottomSheet) } -@Suppress("UnusedPrivateMember") +@Suppress("LongParameterList") @Composable private fun Content( state: MarketsTokenDetailsUM, + backgroundColor: Color, + addTopBarStatusBarInsets: Boolean, onBackClick: () -> Unit, onHeaderSizeChange: (Dp) -> Unit, portfolioBlock: @Composable (Modifier) -> Unit, modifier: Modifier = Modifier, ) { - val backgroundColor = LocalMainBottomSheetColor.current.value val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } Column( modifier = modifier .drawBehind { drawRect(backgroundColor) } + .let { if (addTopBarStatusBarInsets) it.statusBarsPadding() else it } .fillMaxSize(), ) { TangemTopAppBar( - modifier = Modifier.onGloballyPositioned { - if (it.size.height > 0) { - with(density) { - onHeaderSizeChange(it.size.height.toDp()) + modifier = Modifier + .onGloballyPositioned { + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } } - } - }, + }, title = state.tokenName, startButton = TopAppBarButtonUM.Back(onBackClick), ) @@ -125,6 +132,7 @@ private fun Content( ) { MarketTokenDetailsChart( modifier = Modifier.fillMaxWidth(), + backgroundColor = backgroundColor, state = state.chartState, ) } @@ -265,6 +273,7 @@ private fun Preview() { TangemThemePreview { Content( modifier = Modifier.background(TangemTheme.colors.background.tertiary), + addTopBarStatusBarInsets = false, state = MarketsTokenDetailsUM( tokenName = "Token Name", priceText = "$0.00000000324", @@ -292,6 +301,7 @@ private fun Preview() { ), onHeaderSizeChange = {}, onBackClick = {}, + backgroundColor = TangemTheme.colors.background.tertiary, portfolioBlock = {}, ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt index 83bc41555e..39970e9671 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -9,17 +9,21 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color import com.tangem.common.ui.charts.MarketChart import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.rememberMarketChartState -import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData @Composable -internal fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, modifier: Modifier = Modifier) { +internal fun MarketTokenDetailsChart( + state: MarketsTokenDetailsUM.ChartState, + backgroundColor: Color, + modifier: Modifier = Modifier, +) { val growingColor = TangemTheme.colors.icon.accent val fallingColor = TangemTheme.colors.icon.warning val neutralColor = TangemTheme.colors.icon.informative @@ -36,7 +40,6 @@ internal fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, mo onMarkerShown = state.onMarkerPointSelected, ) - val backgroundColor = LocalMainBottomSheetColor.current.value val bottomChartAxisHeight = getMarketChartBottomAxisHeight() Box(modifier) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt index 473bc3a518..6023a1c8a2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt @@ -13,7 +13,7 @@ import java.math.BigDecimal internal data class MarketsTokenDetailsUM( val tokenName: String, val priceText: String, - val iconUrl: String, + val iconUrl: String?, val dateTimeText: TextReference, val priceChangePercentText: String, val priceChangeType: PriceChangeType, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt index 298b0b67ed..1419aa7349 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/DefaultMarketsEntryComponent.kt @@ -1,27 +1,23 @@ package com.tangem.features.markets.entry.impl -import androidx.compose.animation.Animatable -import androidx.compose.animation.core.tween import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.arkivanov.decompose.ExperimentalDecomposeApi -import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children -import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.* import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState import com.arkivanov.decompose.router.stack.* import com.arkivanov.decompose.value.Value import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext -import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.decompose.navigation.Router import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.entry.MarketsEntryComponent import com.tangem.features.markets.details.MarketsTokenDetailsComponent -import com.tangem.features.markets.details.toSerializable -import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import com.tangem.domain.markets.toSerializableParam +import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory.Child +import com.tangem.features.markets.entry.impl.ui.EntryBottomSheetContent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -32,20 +28,22 @@ internal class DefaultMarketsEntryComponent @AssistedInject constructor( private val marketsEntryChildFactory: MarketsEntryChildFactory, ) : MarketsEntryComponent, AppComponentContext by context { - private val stackNavigation = StackNavigation() + private val stackNavigation = StackNavigation() - val stack: Value> = childStack( + val stack: Value> = childStack( key = "main", source = stackNavigation, - serializer = MarketsEntryChildFactory.Child.serializer(), - initialConfiguration = MarketsEntryChildFactory.Child.TokenList, + serializer = Child.serializer(), + initialConfiguration = Child.TokenList, handleBackButton = true, - childFactory = { configuration, componentContext -> + childFactory = { configuration, factoryContext -> marketsEntryChildFactory.createChild( child = configuration, - appComponentContext = childByContext(componentContext), + appComponentContext = childByContext( + componentContext = factoryContext, + router = createRouter(configuration), + ), onTokenSelected = ::marketsListTokenSelected, - onDetailsBack = ::onDetailsBack, ) }, ) @@ -57,103 +55,36 @@ internal class DefaultMarketsEntryComponent @AssistedInject constructor( onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier, ) { - val primary = TangemTheme.colors.background.primary - val secondary = TangemTheme.colors.background.secondary - val backgroundColor = remember { Animatable(primary) } - val stackState = stack.subscribeAsState() - - LocalMainBottomSheetColor.current.value = backgroundColor.value - - Children( - stack = stackState.value, - animation = stackAnimation(slide()), - ) { - when (it.configuration) { - is MarketsEntryChildFactory.Child.TokenDetails -> { - (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( - bottomSheetState = bottomSheetState, - onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, - ) - } - MarketsEntryChildFactory.Child.TokenList -> { - (it.instance as MarketsTokenListComponent).BottomSheetContent( - bottomSheetState = bottomSheetState, - onHeaderSizeChange = onHeaderSizeChange, - modifier = modifier, - ) - } - } - } - - // order of LaunchedEffects is important here - - val activeChild = stackState.value.active.configuration - - LaunchedEffect(activeChild) { - when (activeChild) { - is MarketsEntryChildFactory.Child.TokenDetails -> { - backgroundColor.animateTo( - secondary, - animationSpec = tween(durationMillis = 500), - ) - } - MarketsEntryChildFactory.Child.TokenList -> { - backgroundColor.animateTo( - primary, - animationSpec = tween(durationMillis = 500), - ) - } - } - } - - LaunchedEffect(bottomSheetState.value) { - if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) { - when (bottomSheetState.value) { - BottomSheetState.EXPANDED -> { - backgroundColor.animateTo( - secondary, - animationSpec = tween(durationMillis = 100), - ) - } - BottomSheetState.COLLAPSED -> { - backgroundColor.animateTo( - primary, - animationSpec = tween(durationMillis = 100), - ) - } - } - } - } - - LaunchedEffect(primary, secondary) { - if (backgroundColor.isRunning) return@LaunchedEffect - - when (activeChild) { - is MarketsEntryChildFactory.Child.TokenDetails -> { - backgroundColor.snapTo(secondary) - } - MarketsEntryChildFactory.Child.TokenList -> { - backgroundColor.snapTo(primary) - } - } - } + EntryBottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + stackState = stack.subscribeAsState(), + modifier = modifier, + ) } @OptIn(ExperimentalDecomposeApi::class) private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) { stackNavigation.pushNew( - configuration = MarketsEntryChildFactory.Child.TokenDetails( + configuration = Child.TokenDetails( params = MarketsTokenDetailsComponent.Params( - token = token.toSerializable(), + token = token.toSerializableParam(), appCurrency = appCurrency, ), ), ) } - private fun onDetailsBack() { - stackNavigation.popWhile { it != MarketsEntryChildFactory.Child.TokenList } + private fun AppComponentContext.createRouter(child: Child): Router { + return when (child) { + is Child.TokenDetails -> { + MarketTokenDetailsRouter( + contextRouter = this.router, + stackNavigation = stackNavigation, + ) + } + else -> this.router + } } @AssistedFactory diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketTokenDetailsRouter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketTokenDetailsRouter.kt new file mode 100644 index 0000000000..bd9f68f694 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketTokenDetailsRouter.kt @@ -0,0 +1,21 @@ +package com.tangem.features.markets.entry.impl + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popWhile +import com.tangem.core.decompose.navigation.Route +import com.tangem.core.decompose.navigation.Router +import kotlin.reflect.KClass + +internal class MarketTokenDetailsRouter( + private val contextRouter: Router, + private val stackNavigation: StackNavigation, +) : Router by contextRouter { + + override fun pop(onComplete: (isSuccess: Boolean) -> Unit) { + stackNavigation.popWhile({ it != MarketsEntryChildFactory.Child.TokenList }, onComplete) + } + + override fun popTo(route: Route, onComplete: (isSuccess: Boolean) -> Unit) = error("Not allowed") + + override fun popTo(routeClass: KClass, onComplete: (isSuccess: Boolean) -> Unit) = error("Not allowed") +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt index ad6ba5811c..31e6f5e1e0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/MarketsEntryChildFactory.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory.Child import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent import kotlinx.serialization.Serializable import javax.inject.Inject @@ -31,14 +32,12 @@ internal class MarketsEntryChildFactory @Inject constructor( child: Child, appComponentContext: AppComponentContext, onTokenSelected: (TokenMarket, AppCurrency) -> Unit, - onDetailsBack: () -> Unit, ): Any { return when (child) { is Child.TokenDetails -> { tokenDetailsComponentFactory.create( context = appComponentContext, params = child.params, - onBack = onDetailsBack, ) } is Child.TokenList -> { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt new file mode 100644 index 0000000000..dccea6ad25 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/entry/impl/ui/EntryBottomSheetContent.kt @@ -0,0 +1,128 @@ +package com.tangem.features.markets.entry.impl.ui + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.markets.entry.BottomSheetState +import com.tangem.features.markets.entry.impl.MarketsEntryChildFactory +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent + +@Composable +internal fun EntryBottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + stackState: State>, + modifier: Modifier = Modifier, +) { + val primary = TangemTheme.colors.background.primary + val secondary = TangemTheme.colors.background.secondary + val backgroundColor = remember { Animatable(primary) } + + LocalMainBottomSheetColor.current.value = backgroundColor.value + + Children( + stack = stackState.value, + animation = stackAnimation(slide()), + ) { + when (it.configuration) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + (it.instance as MarketsTokenListComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + } + } + + val activeChild = stackState.value.active.configuration + + BackgroundColorEffects( + activeChild = activeChild, + backgroundColor = backgroundColor, + bottomSheetState = bottomSheetState, + ) +} + +@Composable +private fun BackgroundColorEffects( + activeChild: MarketsEntryChildFactory.Child, + backgroundColor: Animatable, + bottomSheetState: State, +) { + val primary = TangemTheme.colors.background.primary + val secondary = TangemTheme.colors.background.secondary + + // Order of LaunchedEffects is important here + + LaunchedEffect(activeChild) { + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 500), + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 500), + ) + } + } + } + + LaunchedEffect(bottomSheetState.value) { + if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) { + when (bottomSheetState.value) { + BottomSheetState.EXPANDED -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 100), + ) + } + BottomSheetState.COLLAPSED -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 100), + ) + } + } + } + } + + LaunchedEffect(primary, secondary) { + if (backgroundColor.isRunning) return@LaunchedEffect + + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.snapTo(secondary) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.snapTo(primary) + } + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/DefaultTokenMarketBlockComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/DefaultTokenMarketBlockComponent.kt index b5925f483f..e4acc5b43c 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/DefaultTokenMarketBlockComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/DefaultTokenMarketBlockComponent.kt @@ -2,7 +2,9 @@ package com.tangem.features.markets.token.block.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.markets.token.block.TokenMarketBlockComponent @@ -23,7 +25,12 @@ internal class DefaultTokenMarketBlockComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - TokenMarketBlock(modifier) + val state by model.state.collectAsStateWithLifecycle() + + TokenMarketBlock( + modifier = modifier, + state = state, + ) } @AssistedFactory diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/QuotesState.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/QuotesState.kt new file mode 100644 index 0000000000..218669f3d9 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/QuotesState.kt @@ -0,0 +1,8 @@ +package com.tangem.features.markets.token.block.impl.model + +import java.math.BigDecimal + +internal class QuotesState( + val currentPrice: BigDecimal, + val h24Percent: BigDecimal, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt index bf37a78999..dccc252f97 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/model/TokenMarketBlockModel.kt @@ -1,11 +1,25 @@ package com.tangem.features.markets.token.block.impl.model import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.core.decompose.di.ComponentScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.markets.token.block.impl.ui.state.TokenMarketBlockUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -13,9 +27,116 @@ import javax.inject.Inject internal class TokenMarketBlockModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val getTokenQuotesUseCase: GetTokenQuotesUseCase, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, ) : Model() { - val params = paramsContainer.require() + private val params = paramsContainer.require() + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = false) - // token price is available if cryptoCurrency.id.rawCurrencyId != null + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + + private var quotesState: QuotesState? = null + + val state = MutableStateFlow( + TokenMarketBlockUM( + currencySymbol = params.tokenSymbol, + currentPrice = null, + h24Percent = null, + priceChangeType = PriceChangeType.NEUTRAL, + chartData = null, + onClick = ::navigateToMarketDetails, + ), + ) + + init { + startFetching() + } + + private fun startFetching() { + modelScope.launch { + getTokenQuotesUseCase( + tokenId = params.tokenId, + interval = PriceChangeInterval.H24, + ).collect { + it.onRight { res -> + quotesState = QuotesState( + currentPrice = res.fiatRate, + h24Percent = res.priceChange, + ) + + state.value = state.value.copy( + currentPrice = BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = res.fiatRate, + // TODO get currency from quotes use case [REDACTED_TASK_KEY] + fiatCurrencyCode = currentAppCurrency.value.code, + // TODO get currency from quotes use case [REDACTED_TASK_KEY] + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ), + h24Percent = BigDecimalFormatter.formatPercent( + percent = res.priceChange, + useAbsoluteValue = true, + ), + priceChangeType = PriceChangeType.fromBigDecimal(res.priceChange), + ) + } + } + } + + modelScope.launch(dispatchers.main) { + val result = getTokenPriceChartUseCase( + tokenId = params.tokenId, + interval = PriceChangeInterval.H24, + appCurrency = currentAppCurrency.value, // TODO get currency from quotes use case [REDACTED_TASK_KEY] + preview = true, + ) + + result.onRight { res -> + state.update { stateToUpdate -> + stateToUpdate.copy( + chartData = priceAndTimePointValuesConverter.convert( + MarketChartData.Data( + y = res.priceY.toImmutableList(), + x = res.timeStamps.sorted().map { it.toBigDecimal() }.toImmutableList(), + ), + ), + ) + } + } + } + } + + private fun navigateToMarketDetails() { + val quotes = quotesState ?: return + + val tokenParam = TokenMarketParam( + id = params.tokenId, + name = params.tokenSymbol, + imageUrl = params.tokenImageUrl, + symbol = params.tokenSymbol, + tokenQuotes = TokenMarketParam.Quotes( + currentPrice = quotes.currentPrice, + h24Percent = quotes.h24Percent, + weekPercent = null, + monthPercent = null, + ), + ) + + // FIXME navigation crash [REDACTED_TASK_KEY] + router.push( + AppRoute.MarketsTokenDetails( + token = tokenParam, + appCurrency = currentAppCurrency.value, + ), + ) + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt index 48891c8ea4..95285a38b0 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt @@ -1,23 +1,209 @@ package com.tangem.features.markets.token.block.impl.ui +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.tangem.core.ui.components.block.information.InformationBlock +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.charts.MarketChartMini +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.markets.details.impl.model.formatter.toChartType +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.token.block.impl.ui.state.TokenMarketBlockUM +import kotlinx.collections.immutable.toImmutableList +import kotlin.random.Random @Composable -internal fun TokenMarketBlock(modifier: Modifier = Modifier) { - InformationBlock( +internal fun TokenMarketBlock(state: TokenMarketBlockUM, modifier: Modifier = Modifier) { + BlockCard( modifier = modifier, - title = { - Text( - text = "Token Market Block", - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) + enabled = state.currentPrice != null, + onClick = state.onClick, + content = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + LeftSide( + modifier = Modifier.weight(1f), + symbol = state.currencySymbol, + priceText = state.currentPrice, + percentText = state.h24Percent, + type = state.priceChangeType, + ) + SpacerW8() + RightSide( + modifier = Modifier, + priceChangeType = state.priceChangeType, + chartRawData = state.chartData, + ) + } }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LeftSide( + symbol: String, + priceText: String?, + percentText: String?, + type: PriceChangeType, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), ) { - Text("//TODO") + Text( + text = stringResource(id = R.string.wallet_marketplace_block_title, symbol), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + + if (priceText != null && percentText != null) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + Text( + text = priceText, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + PriceChangeInPercent( + valueInPercent = percentText, + type = type, + ) + Text( + text = stringResource(id = R.string.wallet_marketprice_block_update_time), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } else { + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + style = TangemTheme.typography.body2, + ) + } + } +} + +@Composable +fun RightSide(priceChangeType: PriceChangeType?, chartRawData: MarketChartRawData?, modifier: Modifier = Modifier) { + Row( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing10), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + verticalAlignment = Alignment.CenterVertically, + ) { + if (chartRawData != null && priceChangeType != null) { + MarketChartMini( + rawData = chartRawData, + type = priceChangeType.toChartType(), + modifier = Modifier + .requiredSize( + width = TangemTheme.dimens.size56, + height = TangemTheme.dimens.size24, + ), + ) + } else { + RectangleShimmer( + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing2) + .requiredSize( + width = TangemTheme.dimens.size56, + height = TangemTheme.dimens.size20, + ), + ) + } + + if (priceChangeType != null) { + Icon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size20), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_chevron_right_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } else { + RectangleShimmer( + modifier = Modifier + .requiredSize( + width = TangemTheme.dimens.size20, + height = TangemTheme.dimens.size20, + ), + ) + } + } +} + +@Preview(widthDp = 360) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 360) +@Composable +private fun Preview() { + val data = MarketChartRawData( + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + ) + + val state = TokenMarketBlockUM( + currencySymbol = "XRP", + currentPrice = "0,5$", + h24Percent = "0,5%", + priceChangeType = PriceChangeType.UP, + chartData = data, + onClick = {}, + ) + + TangemThemePreview { + Column( + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state, + ) + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state.copy( + currentPrice = "0,0000000000012356786789$", + ), + ) + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state.copy( + currentPrice = null, + chartData = null, + ), + ) + TokenMarketBlock( + modifier = Modifier.fillMaxWidth(), + state = state.copy( + chartData = null, + ), + ) + } } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/state/TokenMarketBlockUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/state/TokenMarketBlockUM.kt new file mode 100644 index 0000000000..475c39e3f9 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/state/TokenMarketBlockUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.markets.token.block.impl.ui.state + +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.marketprice.PriceChangeType + +internal data class TokenMarketBlockUM( + val currencySymbol: String, + val currentPrice: String?, + val h24Percent: String?, + val priceChangeType: PriceChangeType, + val chartData: MarketChartRawData?, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 117f3e2eb6..de5b9043b9 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -78,6 +78,7 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.transaction) implementation(projects.domain.staking) + implementation(projects.domain.markets.models) /** Temp dependency to swap domain */ implementation(projects.features.swap.domain) 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 248526906b..9385875fb1 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 @@ -8,7 +8,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.defaultComponentContext import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.routing.bundle.unbundle +import com.tangem.common.routing.utils.asRouter import com.tangem.core.decompose.context.DefaultAppComponentContext import com.tangem.core.decompose.di.DecomposeComponent import com.tangem.core.ui.UiDependencies @@ -46,6 +48,9 @@ internal class TokenDetailsFragment : ComposeFragment() { @Inject internal lateinit var tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory + @Inject + internal lateinit var appRouter: AppRouter + private var tokenMarketBlockComponent: TokenMarketBlockComponent? = null private val internalTokenDetailsRouter: InnerTokenDetailsRouter @@ -57,25 +62,39 @@ internal class TokenDetailsFragment : ComposeFragment() { super.onCreate(savedInstanceState) if (marketsFeatureToggles.isFeatureEnabled) { + val cryptoCurrency: CryptoCurrency = arguments + ?.getBundle(AppRoute.CurrencyDetails.CRYPTO_CURRENCY_KEY) + ?.unbundle(CryptoCurrency.serializer()) + ?: error("Token Details screen can't open without `CryptoCurrency`") + + val param = cryptoCurrency.toParam() ?: return + val appContext = DefaultAppComponentContext( componentContext = defaultComponentContext(requireActivity().onBackPressedDispatcher), messageHandler = uiDependencies.eventMessageHandler, dispatchers = coroutineDispatcherProvider, hiltComponentBuilder = componentBuilder, + replaceRouter = appRouter.asRouter(), ) - val cryptoCurrency: CryptoCurrency = arguments - ?.getBundle(AppRoute.CurrencyDetails.CRYPTO_CURRENCY_KEY) - ?.unbundle(CryptoCurrency.serializer()) - ?: error("This screen can't open without `CryptoCurrency`") - tokenMarketBlockComponent = tokenMarketBlockComponentFactory.create( appComponentContext = appContext, - params = TokenMarketBlockComponent.Params(cryptoCurrency = cryptoCurrency), + params = param, ) } } + private fun CryptoCurrency.toParam(): TokenMarketBlockComponent.Params? { + val tokenId = id.rawCurrencyId ?: return null // token price is not available + + return TokenMarketBlockComponent.Params( + tokenId = tokenId, + tokenName = name, + tokenSymbol = symbol, + tokenImageUrl = iconUrl, + ) + } + @Composable override fun ScreenContent(modifier: Modifier) { val viewModel = hiltViewModel()