From e651d3c26139ded2c6a9d2ccc7b69599ec79a45b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:18:31 +0300 Subject: [PATCH 01/17] Add portfolio chart data layer Backend endpoint + coordinator for the wallet portfolio value history: - GemDeviceApiClient: POST /v2/devices/portfolio/assets - GetPortfolioData reads held balances, POSTs them, applies the fiat rate, and returns the generated PortfolioData (mirrors iOS PortfolioDataService) - Hilt binding + GetPortfolioDataImplTest --- .../asset/GetPortfolioDataImpl.kt | 55 +++++++++++ .../data/coordinators/di/AssetModule.kt | 13 +++ .../asset/GetPortfolioDataImplTest.kt | 97 +++++++++++++++++++ .../services/gemapi/GemDeviceApiClient.kt | 9 ++ .../assets/coordinators/GetPortfolioData.kt | 12 +++ 5 files changed, 186 insertions(+) create mode 100644 android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt create mode 100644 android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt create mode 100644 android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt new file mode 100644 index 0000000000..52b48bb0f6 --- /dev/null +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt @@ -0,0 +1,55 @@ +package com.gemwallet.android.data.coordinators.asset + +import com.gemwallet.android.application.assets.coordinators.GetPortfolioData +import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.data.services.gemapi.GemDeviceApiClient +import com.gemwallet.android.model.getTotalAmount +import com.wallet.core.primitives.ChartDateValue +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PortfolioAsset +import com.wallet.core.primitives.PortfolioAssetsRequest +import com.wallet.core.primitives.PortfolioChartData +import com.wallet.core.primitives.PortfolioChartType +import com.wallet.core.primitives.PortfolioData +import com.wallet.core.primitives.PortfolioStatistic +import kotlinx.coroutines.flow.firstOrNull +import java.math.BigInteger +import java.util.concurrent.TimeUnit + +class GetPortfolioDataImpl( + private val gemDeviceApiClient: GemDeviceApiClient, + private val assetsRepository: AssetsRepository, +) : GetPortfolioData { + + override suspend fun getPortfolioData( + period: ChartPeriod, + currency: Currency, + ): PortfolioData { + val assets = assetsRepository.getAssetsInfo().firstOrNull().orEmpty() + .mapNotNull { assetInfo -> + val total = assetInfo.balance.balance.getTotalAmount() + if (total <= BigInteger.ZERO) return@mapNotNull null + PortfolioAsset(assetId = assetInfo.asset.id, value = total.toString()) + } + val portfolio = gemDeviceApiClient.getPortfolioAssets( + period = period.string, + request = PortfolioAssetsRequest(assets = assets), + ) + val rate = assetsRepository.getCurrencyRate(currency).firstOrNull()?.rate + ?: return PortfolioData(charts = emptyList(), statistics = emptyList(), availablePeriods = emptyList()) + + val values = portfolio.values + .sortedBy { it.timestamp } + .map { ChartDateValue(date = TimeUnit.SECONDS.toMillis(it.timestamp.toLong()), value = it.value * rate) } + val statistics = listOfNotNull( + portfolio.allTimeHigh?.let { PortfolioStatistic.AllTimeHigh(it) }, + portfolio.allTimeLow?.let { PortfolioStatistic.AllTimeLow(it) }, + ) + return PortfolioData( + charts = listOf(PortfolioChartData(chartType = PortfolioChartType.Value, values = values)), + statistics = statistics, + availablePeriods = emptyList(), + ) + } +} diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt index 36a071b372..cf419acc43 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt @@ -15,6 +15,7 @@ import com.gemwallet.android.application.assets.coordinators.GetHideBalancesStat import com.gemwallet.android.application.assets.coordinators.GetImportInProgress import com.gemwallet.android.application.assets.coordinators.GetSearchLists import com.gemwallet.android.application.assets.coordinators.GetShowWelcomeBanner +import com.gemwallet.android.application.assets.coordinators.GetPortfolioData import com.gemwallet.android.application.assets.coordinators.GetWalletSummary import com.gemwallet.android.application.assets.coordinators.EnsureWalletAssets import com.gemwallet.android.application.assets.coordinators.HideAsset @@ -43,6 +44,7 @@ import com.gemwallet.android.data.coordinators.asset.GetHideBalancesStateImpl import com.gemwallet.android.data.coordinators.asset.GetImportInProgressImpl import com.gemwallet.android.data.coordinators.asset.GetSearchListsImpl import com.gemwallet.android.data.coordinators.asset.GetShowWelcomeBannerImpl +import com.gemwallet.android.data.coordinators.asset.GetPortfolioDataImpl import com.gemwallet.android.data.coordinators.asset.GetWalletSummaryImpl import com.gemwallet.android.data.coordinators.asset.DeviceAssetsSyncService import com.gemwallet.android.data.coordinators.asset.EnsureWalletAssetsImpl @@ -62,6 +64,7 @@ import com.gemwallet.android.data.repositories.perpetual.PerpetualRepository import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.repositories.stream.StreamSubscriptionService import com.gemwallet.android.data.services.gemapi.GemApiClient +import com.gemwallet.android.data.services.gemapi.GemDeviceApiClient import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -155,6 +158,16 @@ object AssetModule { assetsRepository = assetsRepository, ) + @Provides + @Singleton + fun provideGetPortfolioData( + gemDeviceApiClient: GemDeviceApiClient, + assetsRepository: AssetsRepository, + ): GetPortfolioData = GetPortfolioDataImpl( + gemDeviceApiClient = gemDeviceApiClient, + assetsRepository = assetsRepository, + ) + @Provides @Singleton fun providePrefetchAssets( diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt new file mode 100644 index 0000000000..c3722d969a --- /dev/null +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt @@ -0,0 +1,97 @@ +package com.gemwallet.android.data.coordinators.asset + +import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.data.services.gemapi.GemDeviceApiClient +import com.gemwallet.android.model.AssetBalance +import com.gemwallet.android.testkit.mockAsset +import com.gemwallet.android.testkit.mockAssetEthereum +import com.gemwallet.android.testkit.mockAssetInfo +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.ChartValue +import com.wallet.core.primitives.ChartValuePercentage +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.FiatRate +import com.wallet.core.primitives.PortfolioAsset +import com.wallet.core.primitives.PortfolioAssets +import com.wallet.core.primitives.PortfolioAssetsRequest +import com.wallet.core.primitives.PortfolioStatistic +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GetPortfolioDataImplTest { + + private val gemDeviceApiClient = mockk() + private val assetsRepository = mockk(relaxed = true) + + private val subject = GetPortfolioDataImpl( + gemDeviceApiClient = gemDeviceApiClient, + assetsRepository = assetsRepository, + ) + + private fun portfolio(allTimeHigh: ChartValuePercentage? = null) = PortfolioAssets( + totalValue = 5.0f, + values = listOf( + ChartValue(timestamp = 2, value = 3.0f), + ChartValue(timestamp = 1, value = 2.0f), + ), + allTimeHigh = allTimeHigh, + allocation = emptyList(), + ) + + @Test + fun getPortfolioData_requestsOnlyHeldAssets_andConvertsSortedValues() = runTest { + val bitcoin = mockAsset() + val ethereum = mockAssetEthereum() + val held = mockAssetInfo(asset = bitcoin, balance = AssetBalance.create(bitcoin, available = "1000")) + val empty = mockAssetInfo(asset = ethereum, balance = AssetBalance.create(ethereum)) + every { assetsRepository.getAssetsInfo() } returns flowOf(listOf(held, empty)) + every { assetsRepository.getCurrencyRate(Currency.EUR) } returns flowOf(FiatRate("EUR", 2.0)) + coEvery { gemDeviceApiClient.getPortfolioAssets("day", any()) } returns portfolio() + + val result = subject.getPortfolioData(period = ChartPeriod.Day, currency = Currency.EUR) + + coVerify { + gemDeviceApiClient.getPortfolioAssets( + "day", + PortfolioAssetsRequest(assets = listOf(PortfolioAsset(assetId = bitcoin.id, value = "1000"))), + ) + } + val values = result.charts.single().values + assertEquals(listOf(1_000L, 2_000L), values.map { it.date }) + assertEquals(listOf(4.0, 6.0), values.map { it.value }) + } + + @Test + fun getPortfolioData_mapsAllTimeStatistics() = runTest { + val bitcoin = mockAsset() + every { assetsRepository.getAssetsInfo() } returns + flowOf(listOf(mockAssetInfo(asset = bitcoin, balance = AssetBalance.create(bitcoin, available = "1")))) + every { assetsRepository.getCurrencyRate(Currency.USD) } returns flowOf(FiatRate("USD", 1.0)) + val allTimeHigh = ChartValuePercentage(date = 10L, value = 99f, percentage = 5f) + coEvery { gemDeviceApiClient.getPortfolioAssets(any(), any()) } returns portfolio(allTimeHigh = allTimeHigh) + + val result = subject.getPortfolioData(period = ChartPeriod.Day, currency = Currency.USD) + + assertEquals(listOf(PortfolioStatistic.AllTimeHigh(allTimeHigh)), result.statistics) + } + + @Test + fun getPortfolioData_returnsEmptyChartsWhenRateMissing() = runTest { + val bitcoin = mockAsset() + every { assetsRepository.getAssetsInfo() } returns + flowOf(listOf(mockAssetInfo(asset = bitcoin, balance = AssetBalance.create(bitcoin, available = "1000")))) + every { assetsRepository.getCurrencyRate(Currency.EUR) } returns flowOf(null) + coEvery { gemDeviceApiClient.getPortfolioAssets(any(), any()) } returns portfolio() + + val result = subject.getPortfolioData(period = ChartPeriod.Day, currency = Currency.EUR) + + assertTrue(result.charts.isEmpty()) + } +} diff --git a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt index a255d73373..be388d68ac 100644 --- a/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt +++ b/android/data/services/remote-gem/src/main/kotlin/com/gemwallet/android/data/services/gemapi/GemDeviceApiClient.kt @@ -12,6 +12,8 @@ import com.wallet.core.primitives.InAppNotification import com.wallet.core.primitives.NFTAssetData import com.wallet.core.primitives.NFTData import com.wallet.core.primitives.NameRecord +import com.wallet.core.primitives.PortfolioAssets +import com.wallet.core.primitives.PortfolioAssetsRequest import com.wallet.core.primitives.PriceAlert import com.wallet.core.primitives.RedemptionRequest import com.wallet.core.primitives.RedemptionResult @@ -141,6 +143,13 @@ interface GemDeviceApiClient { @GET("/v2/devices/assets") suspend fun getAssets(@Tag walletId: WalletId, @Query("from_timestamp") fromTimestamp: Long): List + // Portfolio + @POST("/v2/devices/portfolio/assets") + suspend fun getPortfolioAssets( + @Query("period") period: String, + @Body request: PortfolioAssetsRequest, + ): PortfolioAssets + // NFT @GET("/v2/devices/nft_assets") suspend fun getNFTs(@Tag walletId: WalletId): List? diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt new file mode 100644 index 0000000000..250527f371 --- /dev/null +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt @@ -0,0 +1,12 @@ +package com.gemwallet.android.application.assets.coordinators + +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PortfolioData + +interface GetPortfolioData { + suspend fun getPortfolioData( + period: ChartPeriod, + currency: Currency, + ): PortfolioData +} From caf0fe9449f62f4ec6b682f3f509f47ed82f9969 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:25:13 +0300 Subject: [PATCH 02/17] Make chart period panel and all-time rows reusable Prepare the shared chart components so the portfolio scene can reuse them: - PeriodsPanel/ChartStateView take an optional periods list (defaults to all entries) so a chart can restrict the selector; asset and perpetual charts are unchanged - allTimeProperties is now internal and drops a dead asset parameter, so it can render the portfolio all-time high/low rows too --- .../features/asset/presents/chart/AssetChartScene.kt | 8 ++++---- .../com/gemwallet/android/ui/components/PeriodsPanel.kt | 5 +++-- .../android/ui/components/chart/ChartStateView.kt | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/AssetChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/AssetChartScene.kt index 8f8b9d69b6..ad174f3f3c 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/AssetChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/AssetChartScene.kt @@ -115,7 +115,7 @@ fun AssetChartScene( assetMarket(it.currency, it.asset, it.marketInfo) assetContract(it.asset, it.explorerName) assetSupply(it.asset, it.marketInfo) - assetAllTime(it.currency, it.asset, it.marketInfo) + assetAllTime(it.currency, it.marketInfo) links(it.assetLinks, uriHandler, context) } } @@ -200,14 +200,14 @@ private fun LazyListScope.assetSupply(asset: Asset, marketInfo: AssetMarket?) { marketProperties(asset, supplyItems) } -private fun LazyListScope.assetAllTime(currency: Currency, asset: Asset, marketInfo: AssetMarket?) { +private fun LazyListScope.assetAllTime(currency: Currency, marketInfo: AssetMarket?) { marketInfo ?: return val allTime = listOfNotNull( marketInfo.allTimeHighValue?.let { AllTimeUIModel.High(it.date, it.value.toDouble(), it.percentage.toDouble()) }, marketInfo.allTimeLowValue?.let { AllTimeUIModel.Low(it.date, it.value.toDouble(), it.percentage.toDouble()) }, ) - allTimeProperties(asset, currency, allTime) + allTimeProperties(currency, allTime) } internal fun buildMarketItems( @@ -313,7 +313,7 @@ private fun LazyListScope.marketProperties(asset: Asset, items: List) { +internal fun LazyListScope.allTimeProperties(currency: Currency, items: List) { val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM) itemsPositioned(items) { position, item -> diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/PeriodsPanel.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/PeriodsPanel.kt index 6f830368ad..733cae0649 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/PeriodsPanel.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/PeriodsPanel.kt @@ -29,14 +29,15 @@ import com.wallet.core.primitives.ChartPeriod @Composable fun PeriodsPanel( period: ChartPeriod, - onSelect: (ChartPeriod) -> Unit + onSelect: (ChartPeriod) -> Unit, + periods: List = ChartPeriod.entries, ) { Row( modifier = Modifier.padding(start = paddingDefault, end = paddingDefault, bottom = paddingDefault), horizontalArrangement = Arrangement.spacedBy(0.dp), verticalAlignment = Alignment.CenterVertically, ) { - ChartPeriod.entries.forEach { + periods.forEach { PeriodButton(it.title(), it == period) { onSelect(it) } } } diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt index 045162393b..5963dc427d 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt @@ -32,6 +32,7 @@ fun ChartStateView( period: ChartPeriod, onPeriodSelect: (ChartPeriod) -> Unit, modifier: Modifier = Modifier, + periods: List = ChartPeriod.entries, chartBody: @Composable BoxScope.() -> Unit, ) { Column( @@ -67,7 +68,7 @@ fun ChartStateView( } } } - PeriodsPanel(period, onPeriodSelect) + PeriodsPanel(period, onPeriodSelect, periods) } } From af1d3afb20082938dfad6d2a988e38d722abfe66 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:32:25 +0300 Subject: [PATCH 03/17] Add portfolio chart screen PortfolioChartViewModel + PortfolioChartScene showing the wallet value history plus all-time high/low: - ViewModel holds the selected period in-memory (default All, matching iOS PortfolioState); maps PortfolioData's ChartDateValue series onto ChartUIModel via a new date-value overload (no seconds/millis round-trip); timeout and min-points are named constants - Scene reuses ChartStateView / GemLineChart / chartHeader / allTimeProperties and renders the generated PortfolioStatistic; the period set is a local constant - PortfolioChartViewModelTest keeps a collector on the WhileSubscribed flow and awaits the refetched data, the way the scene subscribes via collectAsStateWithLifecycle --- .../presents/chart/PortfolioChartScene.kt | 120 ++++++++++++++++ .../viewmodels/chart/models/ChartUIModel.kt | 23 +++ .../viewmodels/PortfolioChartViewModel.kt | 100 +++++++++++++ .../viewmodels/PortfolioChartViewModelTest.kt | 135 ++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt create mode 100644 android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt create mode 100644 android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt new file mode 100644 index 0000000000..eb4a942904 --- /dev/null +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -0,0 +1,120 @@ +package com.gemwallet.android.features.asset.presents.chart + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.asset.viewmodels.chart.models.AllTimeUIModel +import com.gemwallet.android.features.asset.viewmodels.chart.models.chartHeader +import com.gemwallet.android.features.asset.viewmodels.chart.viewmodels.PortfolioChartViewModel +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.chart.ChartStateView +import com.gemwallet.android.ui.components.chart.GemLineChart +import com.gemwallet.android.ui.components.screen.Scene +import com.gemwallet.android.ui.models.chart.ChartViewState +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.PortfolioStatistic + +private val portfolioChartPeriods = listOf( + ChartPeriod.Day, + ChartPeriod.Week, + ChartPeriod.Month, + ChartPeriod.Year, + ChartPeriod.All, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PortfolioChartScene( + onCancel: () -> Unit, + viewModel: PortfolioChartViewModel = hiltViewModel(), +) { + val statistics by viewModel.statistics.collectAsStateWithLifecycle() + val currency by viewModel.currency.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val pullToRefreshState = rememberPullToRefreshState() + + Scene( + title = stringResource(R.string.wallet_portfolio_title), + onClose = onCancel, + ) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh, + state = pullToRefreshState, + indicator = { + PullToRefreshDefaults.Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = isRefreshing, + state = pullToRefreshState, + ) + }, + ) { + LazyColumn { + item { PortfolioChart(viewModel) } + allTimeProperties(currency, statistics.toAllTimeItems()) + } + } + } +} + +@Composable +private fun PortfolioChart(viewModel: PortfolioChartViewModel) { + val uiModel by viewModel.chartUIModel.collectAsStateWithLifecycle() + val state by viewModel.chartUIState.collectAsStateWithLifecycle() + + key(state.period) { + var selectedIndex by remember { mutableStateOf(null) } + + val displayState = when { + state.period != uiModel.period -> ChartViewState.Loading + else -> state.viewState + } + val chartPoints = uiModel.chartPoints + val selectedPoint = if (displayState == ChartViewState.Ready) { + selectedIndex?.let { chartPoints.getOrNull(it) } + } else { + null + } + + ChartStateView( + state = displayState, + header = chartHeader(uiModel, selectedPoint), + period = state.period, + onPeriodSelect = viewModel::setPeriod, + periods = portfolioChartPeriods, + ) { + GemLineChart( + points = uiModel.renderPoints, + lineColor = MaterialTheme.colorScheme.primary, + selectedIndex = selectedIndex, + onSelectionChanged = { selectedIndex = it }, + minLabel = uiModel.minLabel, + maxLabel = uiModel.maxLabel, + ) + } + } +} + +private fun List.toAllTimeItems(): List = mapNotNull { statistic -> + when (statistic) { + is PortfolioStatistic.AllTimeHigh -> + AllTimeUIModel.High(statistic.content.date, statistic.content.value.toDouble(), statistic.content.percentage.toDouble()) + is PortfolioStatistic.AllTimeLow -> + AllTimeUIModel.Low(statistic.content.date, statistic.content.value.toDouble(), statistic.content.percentage.toDouble()) + else -> null + } +} diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt index aed52165a9..82ff150f68 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt @@ -8,6 +8,7 @@ import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.ui.components.chart.ChartPoint import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel import com.gemwallet.android.ui.models.chart.ChartViewState +import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.ChartValue import com.wallet.core.primitives.Currency @@ -75,6 +76,28 @@ internal fun ChartUIModel.Companion.from( ) } +internal fun ChartUIModel.Companion.from( + values: List, + period: ChartPeriod, + currency: Currency, +): ChartUIModel { + val basePrice = values.firstOrNull { it.value != 0.0 }?.value ?: 0.0 + val currencyFormatter = CurrencyFormatter(currency = currency) + val points = values.map { value -> + PricePoint( + y = value.value.toFloat(), + price = value.value, + priceChangePercentage = PriceChange.percentage(from = basePrice, to = value.value), + timestamp = value.date, + ) + } + return ChartUIModel( + period = period, + chartPoints = points, + priceFormatter = currencyFormatter::string, + ) +} + fun chartHeader(uiModel: ChartUIModel, selectedPoint: PricePoint?): ChartHeaderUIModel? { val target = selectedPoint ?: uiModel.chartPoints.lastOrNull() ?: return null return ChartHeaderUIModel.build( diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt new file mode 100644 index 0000000000..8ecffbbcd4 --- /dev/null +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -0,0 +1,100 @@ +package com.gemwallet.android.features.asset.viewmodels.chart.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.assets.coordinators.GetPortfolioData +import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency +import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel +import com.gemwallet.android.features.asset.viewmodels.chart.models.from +import com.gemwallet.android.ui.models.chart.ChartViewState +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PortfolioChartType +import com.wallet.core.primitives.PortfolioData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +private const val MinChartPoints = 2 +private const val StopTimeoutMillis = 5_000L + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class PortfolioChartViewModel @Inject constructor( + getCurrentCurrency: GetCurrentCurrency, + private val getPortfolioData: GetPortfolioData, +) : ViewModel() { + private val selectedPeriod = MutableStateFlow(ChartPeriod.All) + private val viewState = MutableStateFlow(ChartViewState.Loading) + private val refreshTrigger = MutableStateFlow(0L) + private val refreshState = MutableStateFlow(false) + + val chartUIState = combine(selectedPeriod, viewState) { period, viewState -> + ChartUIModel.State(period = period, viewState = viewState) + }.stateIn(viewModelScope, SharingStarted.Eagerly, ChartUIModel.State()) + + val isRefreshing = refreshState.asStateFlow() + + private val portfolio = combine( + selectedPeriod, + getCurrentCurrency.getCurrency().distinctUntilChanged(), + refreshTrigger, + ) { period, currency, _ -> period to currency } + .mapLatest { (period, currency) -> + try { + val data = getPortfolioData.getPortfolioData(period, currency) + viewState.value = if (data.chartValues().size < MinChartPoints) ChartViewState.Empty else ChartViewState.Ready + data to currency + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + viewState.value = ChartViewState.Error + null + } finally { + refreshState.value = false + } + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), null) + + val chartUIModel = combine(selectedPeriod, portfolio) { period, data -> + val (portfolioData, currency) = data ?: return@combine ChartUIModel(period = period) + ChartUIModel.from(portfolioData.chartValues(), period, currency) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel()) + + val statistics = portfolio + .map { it?.first?.statistics.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), emptyList()) + + val currency = portfolio + .map { it?.second ?: Currency.USD } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), Currency.USD) + + fun setPeriod(period: ChartPeriod) { + if (period == selectedPeriod.value) { + return + } + selectedPeriod.value = period + viewState.value = ChartViewState.Loading + } + + fun refresh() { + refreshState.value = true + viewState.value = ChartViewState.Loading + refreshTrigger.value = refreshTrigger.value + 1 + } +} + +private fun PortfolioData.chartValues() = + charts.firstOrNull { it.chartType == PortfolioChartType.Value }?.values.orEmpty() diff --git a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt new file mode 100644 index 0000000000..f151767ee8 --- /dev/null +++ b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt @@ -0,0 +1,135 @@ +package com.gemwallet.android.features.asset.viewmodels.chart.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gemwallet.android.application.assets.coordinators.GetPortfolioData +import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency +import com.gemwallet.android.ui.models.chart.ChartViewState +import com.wallet.core.primitives.ChartDateValue +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.ChartValuePercentage +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PortfolioChartData +import com.wallet.core.primitives.PortfolioChartType +import com.wallet.core.primitives.PortfolioData +import com.wallet.core.primitives.PortfolioStatistic +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PortfolioChartViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val currencyFlow = MutableStateFlow(Currency.USD) + private val viewModels = mutableListOf() + + private val getCurrentCurrency = mockk(relaxed = true) { + every { getCurrency() } returns currencyFlow + } + private val getPortfolioData = mockk(relaxed = true) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + viewModels.forEach { viewModel -> + val job = viewModel.viewModelScope.coroutineContext.job + job.cancel() + while (!job.isCompleted) { + testDispatcher.scheduler.advanceUntilIdle() + } + } + viewModels.clear() + Dispatchers.resetMain() + } + + @Test + fun `renders chart when portfolio has values`() = runTest(testDispatcher) { + coEvery { getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(10f, 12f, 14f)) + + val viewModel = createViewModel() + val uiModel = viewModel.chartUIModel.first { it.chartPoints.size == 3 } + + assertEquals(3, uiModel.chartPoints.size) + assertEquals(ChartViewState.Ready, viewModel.chartUIState.value.viewState) + } + + @Test + fun `initial request uses all period by default`() = runTest(testDispatcher) { + coEvery { getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(1f, 2f)) + + val viewModel = createViewModel() + viewModel.chartUIModel.first { it.chartPoints.size == 2 } + + assertEquals(ChartPeriod.All, viewModel.chartUIState.value.period) + coVerify(exactly = 1) { getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) } + } + + @Test + fun `selecting period updates state and refetches`() = runTest(testDispatcher) { + coEvery { getPortfolioData.getPortfolioData(any(), any()) } returns portfolioData(listOf(1f, 2f)) + coEvery { getPortfolioData.getPortfolioData(ChartPeriod.Month, Currency.USD) } returns portfolioData(listOf(1f, 2f, 3f)) + val viewModel = createViewModel() + backgroundScope.launch { viewModel.chartUIModel.collect {} } + + viewModel.setPeriod(ChartPeriod.Month) + val model = viewModel.chartUIModel.first { it.chartPoints.size == 3 } + + assertEquals(ChartPeriod.Month, model.period) + coVerify { getPortfolioData.getPortfolioData(ChartPeriod.Month, Currency.USD) } + } + + @Test + fun `exposes all time statistics from portfolio`() = runTest(testDispatcher) { + val allTimeHigh = ChartValuePercentage(date = 1L, value = 99f, percentage = 5f) + coEvery { + getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) + } returns portfolioData(listOf(1f, 2f), statistics = listOf(PortfolioStatistic.AllTimeHigh(allTimeHigh))) + + val viewModel = createViewModel() + val statistics = viewModel.statistics.first { it.isNotEmpty() } + + assertEquals(listOf(PortfolioStatistic.AllTimeHigh(allTimeHigh)), statistics) + } + + private fun portfolioData( + values: List, + statistics: List = emptyList(), + ) = PortfolioData( + charts = listOf( + PortfolioChartData( + chartType = PortfolioChartType.Value, + values = values.mapIndexed { index, value -> + ChartDateValue(date = TimeUnit.SECONDS.toMillis((index + 1).toLong()), value = value.toDouble()) + }, + ), + ), + statistics = statistics, + availablePeriods = emptyList(), + ) + + private fun createViewModel() = PortfolioChartViewModel( + getCurrentCurrency = getCurrentCurrency, + getPortfolioData = getPortfolioData, + ).also(viewModels::add) +} From 73392447200467da213b315f617da2bb59ddee17 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:35:17 +0300 Subject: [PATCH 04/17] Wire portfolio chart navigation - PortfolioChartRoute + portfolioChartScreen entry (Navigation3) - navigator.openPortfolioChart(), mirroring openAssetChart - register the entry in WalletNavGraph --- .../android/ui/navigation/RootRoute.kt | 2 ++ .../android/ui/navigation/WalletNavGraph.kt | 5 +++++ .../android/ui/navigation/routes/Portfolio.kt | 17 +++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt index 8703aa031d..3329d47ef3 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt @@ -69,6 +69,7 @@ import com.gemwallet.android.ui.navigation.routes.SwapPairRoute import com.gemwallet.android.ui.navigation.routes.SwapRoute import com.gemwallet.android.ui.navigation.routes.SwapSelectRoute import com.gemwallet.android.ui.navigation.routes.TransactionDetailsRoute +import com.gemwallet.android.ui.navigation.routes.PortfolioChartRoute import com.gemwallet.android.ui.navigation.routes.WalletDetailsRoute import com.gemwallet.android.ui.navigation.routes.WalletImageRoute import com.gemwallet.android.ui.navigation.routes.WalletPhraseRoute @@ -189,6 +190,7 @@ class WalletNavigator( fun openAddAsset() = push(AddAssetRoute) fun openAsset(assetId: AssetId) = push(AssetRoute(assetId)) fun openAssetChart(assetId: AssetId) = push(AssetChartRoute(assetId)) + fun openPortfolioChart() = push(PortfolioChartRoute) fun openTransaction(transactionId: TransactionId) = push(TransactionDetailsRoute(transactionId)) fun openBridgeConnections() = push(BridgeConnectionsRoute) fun openBridgeConnectionDetails(connectionId: String) = push(BridgeConnectionDetailsRoute(connectionId)) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt index 6d269a1b60..876566f808 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt @@ -37,6 +37,7 @@ import com.gemwallet.android.ui.navigation.routes.addAssetScreen import com.gemwallet.android.ui.navigation.routes.amount import com.gemwallet.android.ui.navigation.routes.assetChartScreen import com.gemwallet.android.ui.navigation.routes.assetScreen +import com.gemwallet.android.ui.navigation.routes.portfolioChartScreen import com.gemwallet.android.ui.navigation.routes.bridgesScreen import com.gemwallet.android.ui.navigation.routes.confirm import com.gemwallet.android.ui.navigation.routes.fiatScreen @@ -131,6 +132,10 @@ fun WalletNavGraph( onCancel = onCancel, ) + portfolioChartScreen( + onCancel = onCancel, + ) + swap( navigator = navigator, onConfirm = navigator::openConfirm, diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt new file mode 100644 index 0000000000..0cfca69b17 --- /dev/null +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt @@ -0,0 +1,17 @@ +package com.gemwallet.android.ui.navigation.routes + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.gemwallet.android.features.asset.presents.chart.PortfolioChartScene +import kotlinx.serialization.Serializable + +@Serializable +data object PortfolioChartRoute : NavKey + +fun EntryProviderScope.portfolioChartScreen( + onCancel: () -> Unit, +) { + entry { + PortfolioChartScene(onCancel = onCancel) + } +} From 85a1ceb2c36760f6526ec7587c7f1a9c60bf1986 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:35:26 +0300 Subject: [PATCH 05/17] Open portfolio chart from the balance change subtitle - AmountListHead gains an optional onSubtitleClick (Role.Button); the clip, ripple, and a show-chart icon only apply when it is set, so asset/ perpetual/confirm headers are unchanged. The icon mirrors iOS WalletHeaderViewModel.subtitleImage (chart.line.uptrend.xyaxis in footnote size and secondary color) - AssetsHead threads it through as onPortfolio, dispatched as AssetsAction.Portfolio through the assets screen's sealed action and wired to navigator.openPortfolioChart in MainScreen, mirroring iOS WalletHeaderView.onSubtitleAction --- .../android/features/main/views/MainScreen.kt | 1 + .../features/assets/views/AssetsAction.kt | 1 + .../features/assets/views/AssetsScreen.kt | 1 + .../assets/views/components/AssetsHead.kt | 2 ++ .../ui/components/list_head/AmountListHead.kt | 24 ++++++++++++++++++- .../gemwallet/android/ui/icons/AppIcons.kt | 1 + .../src/main/res/drawable/ic_show_chart.xml | 10 ++++++++ 7 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 android/ui/src/main/res/drawable/ic_show_chart.xml diff --git a/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt b/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt index b7c5ed77e6..cd9b4f5abe 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/features/main/views/MainScreen.kt @@ -202,6 +202,7 @@ fun MainScreen( AssetsAction.Receive -> navigator.openReceive() AssetsAction.Buy -> navigator.openBuy() AssetsAction.Swap -> navigator.openSwap() + AssetsAction.Portfolio -> navigator.openPortfolioChart() AssetsAction.Perpetuals -> navigator.openPerpetuals() is AssetsAction.OpenPerpetualDetails -> navigator.openPerpetualDetails(action.assetId) is AssetsAction.OpenAsset -> navigator.openAsset(action.assetId) diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt index 583d5b8c1e..9afe1e7233 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsAction.kt @@ -10,6 +10,7 @@ sealed interface AssetsAction { data object Receive : AssetsAction data object Buy : AssetsAction data object Swap : AssetsAction + data object Portfolio : AssetsAction data object Perpetuals : AssetsAction data class OpenPerpetualDetails(val assetId: AssetId) : AssetsAction data class OpenAsset(val assetId: AssetId) : AssetsAction diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt index d78958c94f..1ca9bfe04b 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/AssetsScreen.kt @@ -140,6 +140,7 @@ fun AssetsScreen( onReceiveClick = { onAction(AssetsAction.Receive) }, onBuyClick = { onAction(AssetsAction.Buy) }, onSwapClick = { onAction(AssetsAction.Swap) }, + onPortfolio = { onAction(AssetsAction.Portfolio) }, onHideBalances = viewModel::hideBalances ) } diff --git a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/components/AssetsHead.kt b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/components/AssetsHead.kt index ae0e06a5f0..ef42aba05c 100644 --- a/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/components/AssetsHead.kt +++ b/android/features/assets/presents/src/main/kotlin/com/gemwallet/android/features/assets/views/components/AssetsHead.kt @@ -15,6 +15,7 @@ internal fun AssetsHead( onBuyClick: () -> Unit, onSwapClick: () -> Unit, onHideBalances: () -> Unit, + onPortfolio: () -> Unit, ) { walletSummary ?: return @@ -27,6 +28,7 @@ internal fun AssetsHead( changedValue = walletSummary.changedValue?.valueFormatted, changedPercentages = walletSummary.changedValue?.changePercentageFormatted, changeState = walletSummary.changedValue?.state ?: ValueDirection.None, + onSubtitleClick = onPortfolio, actions = { AssetHeadActions( walletType = walletSummary.walletType, diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_head/AmountListHead.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_head/AmountListHead.kt index 97e351d06c..6e30a130a0 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_head/AmountListHead.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_head/AmountListHead.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight @@ -78,6 +79,7 @@ import com.gemwallet.android.ui.theme.paddingSmall import com.gemwallet.android.ui.theme.smallIconSize import com.gemwallet.android.ui.theme.space10 import com.gemwallet.android.ui.theme.space2 +import com.gemwallet.android.ui.theme.tinyIconSize import com.wallet.core.primitives.Asset import com.wallet.core.primitives.WalletType import kotlin.math.floor @@ -94,6 +96,7 @@ fun AmountListHead( changedPercentages: String? = null, changeState: ValueDirection = ValueDirection.None, onClick: (() -> Unit)? = null, + onSubtitleClick: (() -> Unit)? = null, actions: (@Composable () -> Unit)? = null, ) { val hidden = hideToggle.isHidden @@ -145,7 +148,18 @@ fun AmountListHead( fontWeight = FontWeight.Medium, ) Row( - modifier = Modifier.height(headerChangeTextHeight), + modifier = Modifier + .then( + if (onSubtitleClick != null) { + Modifier + .clip(CircleShape) + .clickable(role = Role.Button, onClick = onSubtitleClick) + .padding(horizontal = paddingSmall) + } else { + Modifier + } + ) + .height(headerChangeTextHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(space2), ) { @@ -161,6 +175,14 @@ fun AmountListHead( style = changeTextStyle, ) } + if (onSubtitleClick != null) { + Icon( + imageVector = AppIcons.ShowChart, + tint = MaterialTheme.colorScheme.secondary, + contentDescription = null, + modifier = Modifier.size(tinyIconSize), + ) + } } } } diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/icons/AppIcons.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/icons/AppIcons.kt index f462fdc916..dea4dbd21c 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/icons/AppIcons.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/icons/AppIcons.kt @@ -54,6 +54,7 @@ object AppIcons { val Settings: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_settings) val SettingsOutlined: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_settings_outlined) val Share: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_share) + val ShowChart: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_show_chart) val StarOutlined: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_star_outlined) val SwapVert: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_swap_vert) val Tune: ImageVector @Composable get() = ImageVector.vectorResource(R.drawable.ic_tune) diff --git a/android/ui/src/main/res/drawable/ic_show_chart.xml b/android/ui/src/main/res/drawable/ic_show_chart.xml new file mode 100644 index 0000000000..c4364d75b1 --- /dev/null +++ b/android/ui/src/main/res/drawable/ic_show_chart.xml @@ -0,0 +1,10 @@ + + + From 04156124bced17aa8ea3204d2f5c981b5be30450 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:32:41 +0300 Subject: [PATCH 06/17] Add perpetuals portfolio to the data layer - PerpetualService.getPortfolio(address) -> gateway.getPerpetualPortfolio, with GemPerpetualPortfolio/timeframe/summary/date-value DTO mappers - GetPortfolioData takes a PortfolioType and dispatches wallet vs perpetuals, like iOS PortfolioDataService.getPortfoliData(input:); the perpetuals side mirrors iOS getPerpetualData (pnl + value charts, unrealizedPnl/leverage/ margin/allTimePnl/volume statistics, availablePeriods from the account timeframes) - Failures surface like iOS: a gateway error propagates and a missing hyperliquid account throws, so the chart shows the error state instead of a silent empty portfolio - The wallet side now returns data-driven availablePeriods - Hilt DI + coordinator test updated; ViewModel still requests Wallet (perpetuals UI lands in the follow-up commits) --- .../blockchain/gemstone/GemPerpetualMapper.kt | 42 +++++++++++ .../blockchain/services/PerpetualService.kt | 4 ++ .../asset/GetPortfolioDataImpl.kt | 72 ++++++++++++++++++- .../data/coordinators/di/AssetModule.kt | 5 ++ .../asset/GetPortfolioDataImplTest.kt | 22 +++++- .../viewmodels/PortfolioChartViewModel.kt | 3 +- .../assets/coordinators/GetPortfolioData.kt | 2 + 7 files changed, 143 insertions(+), 7 deletions(-) diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt index 17e0db671f..982f4724c9 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt @@ -5,7 +5,9 @@ import com.gemwallet.android.ext.toAssetId import com.gemwallet.android.ext.toPerpetualId import com.wallet.core.primitives.ChartCandleStick import com.wallet.core.primitives.ChartCandleUpdate +import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.Perpetual +import com.wallet.core.primitives.PerpetualAccountSummary import com.wallet.core.primitives.PerpetualBalance import com.wallet.core.primitives.PerpetualData import com.wallet.core.primitives.PerpetualDirection @@ -13,17 +15,23 @@ import com.wallet.core.primitives.PerpetualMarginType import com.wallet.core.primitives.PerpetualMarketData import com.wallet.core.primitives.PerpetualMetadata import com.wallet.core.primitives.PerpetualOrderType +import com.wallet.core.primitives.PerpetualPortfolio +import com.wallet.core.primitives.PerpetualPortfolioTimeframeData import com.wallet.core.primitives.PerpetualPosition import com.wallet.core.primitives.PerpetualPositionsSummary import com.wallet.core.primitives.PerpetualProvider import com.wallet.core.primitives.PerpetualTriggerOrder import uniffi.gemstone.GemChartCandleStick import uniffi.gemstone.GemChartCandleUpdate +import uniffi.gemstone.GemChartDateValue +import uniffi.gemstone.GemPerpetualAccountSummary import uniffi.gemstone.GemPerpetualBalance import uniffi.gemstone.GemPerpetualData import uniffi.gemstone.GemPerpetualMarginType import uniffi.gemstone.GemPerpetualMarketData import uniffi.gemstone.GemPerpetualOrderType +import uniffi.gemstone.GemPerpetualPortfolio +import uniffi.gemstone.GemPerpetualPortfolioTimeframeData import uniffi.gemstone.GemPerpetualPosition import uniffi.gemstone.GemPerpetualPositionsSummary import uniffi.gemstone.GemPerpetualTriggerOrder @@ -126,6 +134,40 @@ fun GemChartCandleStick.toDTO(): ChartCandleStick { ) } +fun GemChartDateValue.toDTO(): ChartDateValue { + return ChartDateValue( + date = date * 1_000L, + value = value, + ) +} + +fun GemPerpetualAccountSummary.toDTO(): PerpetualAccountSummary { + return PerpetualAccountSummary( + accountValue = accountValue, + accountLeverage = accountLeverage, + marginUsage = marginUsage, + unrealizedPnl = unrealizedPnl, + ) +} + +fun GemPerpetualPortfolioTimeframeData.toDTO(): PerpetualPortfolioTimeframeData { + return PerpetualPortfolioTimeframeData( + accountValueHistory = accountValueHistory.map { it.toDTO() }, + pnlHistory = pnlHistory.map { it.toDTO() }, + volume = volume, + ) +} + +fun GemPerpetualPortfolio.toDTO(): PerpetualPortfolio { + return PerpetualPortfolio( + day = day?.toDTO(), + week = week?.toDTO(), + month = month?.toDTO(), + allTime = allTime?.toDTO(), + accountSummary = accountSummary?.toDTO(), + ) +} + private fun GemPerpetualTriggerOrder.toDTO(): PerpetualTriggerOrder { return PerpetualTriggerOrder( price = price, diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PerpetualService.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PerpetualService.kt index 235db71c88..cb5ed8877d 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PerpetualService.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/services/PerpetualService.kt @@ -5,6 +5,7 @@ import com.wallet.core.primitives.Chain import com.wallet.core.primitives.ChartCandleStick import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.PerpetualData +import com.wallet.core.primitives.PerpetualPortfolio import com.wallet.core.primitives.PerpetualPositionsSummary import uniffi.gemstone.GemGateway @@ -32,4 +33,7 @@ class PerpetualService( suspend fun getCandleSticks(chain: Chain = Chain.HyperCore, symbol: String, period: ChartPeriod): List { return gateway.getPerpetualCandlesticks(chain.string, symbol, period.string).map { it.toDTO() } } + + suspend fun getPortfolio(chain: Chain = Chain.HyperCore, address: String): PerpetualPortfolio = + gateway.getPerpetualPortfolio(chain.string, address).toDTO() } diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt index 52b48bb0f6..c9f49875d1 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt @@ -1,31 +1,54 @@ package com.gemwallet.android.data.coordinators.asset import com.gemwallet.android.application.assets.coordinators.GetPortfolioData +import com.gemwallet.android.blockchain.services.PerpetualService import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.services.gemapi.GemDeviceApiClient +import com.gemwallet.android.ext.hyperliquidAccount import com.gemwallet.android.model.getTotalAmount import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PerpetualPortfolio +import com.wallet.core.primitives.PerpetualPortfolioTimeframeData import com.wallet.core.primitives.PortfolioAsset import com.wallet.core.primitives.PortfolioAssetsRequest import com.wallet.core.primitives.PortfolioChartData import com.wallet.core.primitives.PortfolioChartType import com.wallet.core.primitives.PortfolioData +import com.wallet.core.primitives.PortfolioMarginUsage import com.wallet.core.primitives.PortfolioStatistic +import com.wallet.core.primitives.PortfolioType import kotlinx.coroutines.flow.firstOrNull import java.math.BigInteger import java.util.concurrent.TimeUnit +private val walletChartPeriods = listOf( + ChartPeriod.Day, + ChartPeriod.Week, + ChartPeriod.Month, + ChartPeriod.Year, + ChartPeriod.All, +) + class GetPortfolioDataImpl( private val gemDeviceApiClient: GemDeviceApiClient, private val assetsRepository: AssetsRepository, + private val perpetualService: PerpetualService, + private val sessionRepository: SessionRepository, ) : GetPortfolioData { override suspend fun getPortfolioData( + type: PortfolioType, period: ChartPeriod, currency: Currency, - ): PortfolioData { + ): PortfolioData = when (type) { + PortfolioType.Wallet -> getWalletData(period, currency) + PortfolioType.Perpetuals -> getPerpetualData(period) + } + + private suspend fun getWalletData(period: ChartPeriod, currency: Currency): PortfolioData { val assets = assetsRepository.getAssetsInfo().firstOrNull().orEmpty() .mapNotNull { assetInfo -> val total = assetInfo.balance.balance.getTotalAmount() @@ -37,7 +60,7 @@ class GetPortfolioDataImpl( request = PortfolioAssetsRequest(assets = assets), ) val rate = assetsRepository.getCurrencyRate(currency).firstOrNull()?.rate - ?: return PortfolioData(charts = emptyList(), statistics = emptyList(), availablePeriods = emptyList()) + ?: return PortfolioData(charts = emptyList(), statistics = emptyList(), availablePeriods = walletChartPeriods) val values = portfolio.values .sortedBy { it.timestamp } @@ -49,7 +72,50 @@ class GetPortfolioDataImpl( return PortfolioData( charts = listOf(PortfolioChartData(chartType = PortfolioChartType.Value, values = values)), statistics = statistics, - availablePeriods = emptyList(), + availablePeriods = walletChartPeriods, + ) + } + + private suspend fun getPerpetualData(period: ChartPeriod): PortfolioData { + val address = checkNotNull(sessionRepository.session().value?.wallet?.hyperliquidAccount?.address) { + "Perpetual account is not available" + } + val portfolio = perpetualService.getPortfolio(address = address) + val timeframe = portfolio.timeframeData(period) + + val charts = listOf( + PortfolioChartData(chartType = PortfolioChartType.Pnl, values = timeframe?.pnlHistory.orEmpty()), + PortfolioChartData( + chartType = PortfolioChartType.Value, + values = timeframe?.accountValueHistory.orEmpty().dropWhile { it.value == 0.0 }, + ), ) + val statistics = buildList { + portfolio.accountSummary?.let { summary -> + add(PortfolioStatistic.UnrealizedPnl(summary.unrealizedPnl)) + add(PortfolioStatistic.AccountLeverage(summary.accountLeverage)) + add(PortfolioStatistic.MarginUsage(PortfolioMarginUsage(accountValue = summary.accountValue, usage = summary.marginUsage))) + } + portfolio.allTime?.let { allTime -> + allTime.pnlHistory.lastOrNull()?.let { add(PortfolioStatistic.AllTimePnl(it.value)) } + add(PortfolioStatistic.Volume(allTime.volume)) + } + } + return PortfolioData(charts = charts, statistics = statistics, availablePeriods = portfolio.availablePeriods()) } } + +private fun PerpetualPortfolio.availablePeriods(): List = listOfNotNull( + day?.let { ChartPeriod.Day }, + week?.let { ChartPeriod.Week }, + month?.let { ChartPeriod.Month }, + allTime?.let { ChartPeriod.Year }, + allTime?.let { ChartPeriod.All }, +) + +private fun PerpetualPortfolio.timeframeData(period: ChartPeriod): PerpetualPortfolioTimeframeData? = when (period) { + ChartPeriod.Hour, ChartPeriod.Day -> day + ChartPeriod.Week -> week + ChartPeriod.Month -> month + ChartPeriod.Year, ChartPeriod.All -> allTime +} diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt index cf419acc43..65afca79e9 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt @@ -57,6 +57,7 @@ import com.gemwallet.android.data.coordinators.asset.SyncAssetInfoImpl import com.gemwallet.android.data.coordinators.asset.SyncAssetsImpl import com.gemwallet.android.data.coordinators.asset.ToggleAssetPinImpl import com.gemwallet.android.data.coordinators.asset.ToggleHideBalancesImpl +import com.gemwallet.android.blockchain.services.PerpetualService import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.config.UserConfig import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet @@ -163,9 +164,13 @@ object AssetModule { fun provideGetPortfolioData( gemDeviceApiClient: GemDeviceApiClient, assetsRepository: AssetsRepository, + perpetualService: PerpetualService, + sessionRepository: SessionRepository, ): GetPortfolioData = GetPortfolioDataImpl( gemDeviceApiClient = gemDeviceApiClient, assetsRepository = assetsRepository, + perpetualService = perpetualService, + sessionRepository = sessionRepository, ) @Provides diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt index c3722d969a..d69a6c46d4 100644 --- a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt @@ -1,8 +1,11 @@ package com.gemwallet.android.data.coordinators.asset +import com.gemwallet.android.blockchain.services.PerpetualService import com.gemwallet.android.data.repositories.assets.AssetsRepository +import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.services.gemapi.GemDeviceApiClient import com.gemwallet.android.model.AssetBalance +import com.gemwallet.android.model.Session import com.gemwallet.android.testkit.mockAsset import com.gemwallet.android.testkit.mockAssetEthereum import com.gemwallet.android.testkit.mockAssetInfo @@ -15,10 +18,12 @@ import com.wallet.core.primitives.PortfolioAsset import com.wallet.core.primitives.PortfolioAssets import com.wallet.core.primitives.PortfolioAssetsRequest import com.wallet.core.primitives.PortfolioStatistic +import com.wallet.core.primitives.PortfolioType import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -29,10 +34,14 @@ class GetPortfolioDataImplTest { private val gemDeviceApiClient = mockk() private val assetsRepository = mockk(relaxed = true) + private val perpetualService = mockk(relaxed = true) + private val sessionRepository = mockk(relaxed = true) private val subject = GetPortfolioDataImpl( gemDeviceApiClient = gemDeviceApiClient, assetsRepository = assetsRepository, + perpetualService = perpetualService, + sessionRepository = sessionRepository, ) private fun portfolio(allTimeHigh: ChartValuePercentage? = null) = PortfolioAssets( @@ -55,7 +64,7 @@ class GetPortfolioDataImplTest { every { assetsRepository.getCurrencyRate(Currency.EUR) } returns flowOf(FiatRate("EUR", 2.0)) coEvery { gemDeviceApiClient.getPortfolioAssets("day", any()) } returns portfolio() - val result = subject.getPortfolioData(period = ChartPeriod.Day, currency = Currency.EUR) + val result = subject.getPortfolioData(PortfolioType.Wallet, period = ChartPeriod.Day, currency = Currency.EUR) coVerify { gemDeviceApiClient.getPortfolioAssets( @@ -77,11 +86,18 @@ class GetPortfolioDataImplTest { val allTimeHigh = ChartValuePercentage(date = 10L, value = 99f, percentage = 5f) coEvery { gemDeviceApiClient.getPortfolioAssets(any(), any()) } returns portfolio(allTimeHigh = allTimeHigh) - val result = subject.getPortfolioData(period = ChartPeriod.Day, currency = Currency.USD) + val result = subject.getPortfolioData(PortfolioType.Wallet, period = ChartPeriod.Day, currency = Currency.USD) assertEquals(listOf(PortfolioStatistic.AllTimeHigh(allTimeHigh)), result.statistics) } + @Test(expected = IllegalStateException::class) + fun getPortfolioData_perpetualsThrowsWithoutAccount() = runTest { + every { sessionRepository.session() } returns MutableStateFlow(null) + + subject.getPortfolioData(PortfolioType.Perpetuals, period = ChartPeriod.All, currency = Currency.USD) + } + @Test fun getPortfolioData_returnsEmptyChartsWhenRateMissing() = runTest { val bitcoin = mockAsset() @@ -90,7 +106,7 @@ class GetPortfolioDataImplTest { every { assetsRepository.getCurrencyRate(Currency.EUR) } returns flowOf(null) coEvery { gemDeviceApiClient.getPortfolioAssets(any(), any()) } returns portfolio() - val result = subject.getPortfolioData(period = ChartPeriod.Day, currency = Currency.EUR) + val result = subject.getPortfolioData(PortfolioType.Wallet, period = ChartPeriod.Day, currency = Currency.EUR) assertTrue(result.charts.isEmpty()) } diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index 8ecffbbcd4..f12057df46 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -11,6 +11,7 @@ import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PortfolioChartType import com.wallet.core.primitives.PortfolioData +import com.wallet.core.primitives.PortfolioType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -54,7 +55,7 @@ class PortfolioChartViewModel @Inject constructor( ) { period, currency, _ -> period to currency } .mapLatest { (period, currency) -> try { - val data = getPortfolioData.getPortfolioData(period, currency) + val data = getPortfolioData.getPortfolioData(PortfolioType.Wallet, period, currency) viewState.value = if (data.chartValues().size < MinChartPoints) ChartViewState.Empty else ChartViewState.Ready data to currency } catch (e: Exception) { diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt index 250527f371..eae1d854f0 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt @@ -3,9 +3,11 @@ package com.gemwallet.android.application.assets.coordinators import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PortfolioData +import com.wallet.core.primitives.PortfolioType interface GetPortfolioData { suspend fun getPortfolioData( + type: PortfolioType, period: ChartPeriod, currency: Currency, ): PortfolioData From b725e12503ed13bd7397eb41dd9000b15d5b185a Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:33:57 +0300 Subject: [PATCH 07/17] Add type and chart-type switching to the portfolio ViewModel - selectedType (wallet/perpetuals) + selectedChartType (value/pnl) drive the coordinator fetch; the chart renders the selected chartType's series and perpetuals values format in USD like iOS's perpetualFormatter (the gateway data carries no fiat rate) - showSegmentedControl gated on ObservePerpetualWallet (mirrors iOS showPerpetuals(for:)); showChartTypePicker when perpetuals is selected - availablePeriods sourced from the data per type; when the fetched data does not offer the selected period it resets to the first available one, like iOS PortfolioSceneViewModel.fetch (a new perpetuals account has no all-time history, so Year/All disappear) - chartState lives in one place: fewer than two points or a flat series with no variation is Empty (iOS ChartValues.hasVariation), a failed request is Error, and toggling the chart type recomputes it for the newly selected series - ViewModel tests cover switching, the period reset, the flat series, and the error state --- .../viewmodels/PortfolioChartViewModel.kt | 79 ++++++++++++++++--- .../viewmodels/PortfolioChartViewModelTest.kt | 67 ++++++++++++++-- 2 files changed, 129 insertions(+), 17 deletions(-) diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index f12057df46..3b69679c69 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -4,9 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.assets.coordinators.GetPortfolioData import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency +import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel import com.gemwallet.android.features.asset.viewmodels.chart.models.from import com.gemwallet.android.ui.models.chart.ChartViewState +import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PortfolioChartType @@ -31,17 +33,40 @@ import javax.inject.Inject private const val MinChartPoints = 2 private const val StopTimeoutMillis = 5_000L +private val defaultPeriods = listOf( + ChartPeriod.Day, + ChartPeriod.Week, + ChartPeriod.Month, + ChartPeriod.Year, + ChartPeriod.All, +) + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class PortfolioChartViewModel @Inject constructor( getCurrentCurrency: GetCurrentCurrency, + observePerpetualWallet: ObservePerpetualWallet, private val getPortfolioData: GetPortfolioData, ) : ViewModel() { + private val _selectedType = MutableStateFlow(PortfolioType.Wallet) + val selectedType = _selectedType.asStateFlow() + + private val _selectedChartType = MutableStateFlow(PortfolioChartType.Pnl) + val selectedChartType = _selectedChartType.asStateFlow() + private val selectedPeriod = MutableStateFlow(ChartPeriod.All) private val viewState = MutableStateFlow(ChartViewState.Loading) private val refreshTrigger = MutableStateFlow(0L) private val refreshState = MutableStateFlow(false) + val showSegmentedControl = observePerpetualWallet() + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val showChartTypePicker = selectedType + .map { it == PortfolioType.Perpetuals } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val chartUIState = combine(selectedPeriod, viewState) { period, viewState -> ChartUIModel.State(period = period, viewState = viewState) }.stateIn(viewModelScope, SharingStarted.Eagerly, ChartUIModel.State()) @@ -49,15 +74,22 @@ class PortfolioChartViewModel @Inject constructor( val isRefreshing = refreshState.asStateFlow() private val portfolio = combine( + selectedType, selectedPeriod, getCurrentCurrency.getCurrency().distinctUntilChanged(), refreshTrigger, - ) { period, currency, _ -> period to currency } - .mapLatest { (period, currency) -> + ) { type, period, currency, _ -> Triple(type, period, currency) } + .mapLatest { (type, period, currency) -> try { - val data = getPortfolioData.getPortfolioData(PortfolioType.Wallet, period, currency) - viewState.value = if (data.chartValues().size < MinChartPoints) ChartViewState.Empty else ChartViewState.Ready - data to currency + val data = getPortfolioData.getPortfolioData(type, period, currency) + val periods = data.availablePeriods + if (periods.isNotEmpty() && !periods.contains(period)) { + selectedPeriod.value = periods.first() + null + } else { + viewState.value = data.chartState(_selectedChartType.value) + data to type.displayCurrency(currency) + } } catch (e: Exception) { currentCoroutineContext().ensureActive() viewState.value = ChartViewState.Error @@ -69,9 +101,9 @@ class PortfolioChartViewModel @Inject constructor( .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), null) - val chartUIModel = combine(selectedPeriod, portfolio) { period, data -> + val chartUIModel = combine(selectedPeriod, selectedChartType, portfolio) { period, chartType, data -> val (portfolioData, currency) = data ?: return@combine ChartUIModel(period = period) - ChartUIModel.from(portfolioData.chartValues(), period, currency) + ChartUIModel.from(portfolioData.chartValues(chartType), period, currency) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel()) val statistics = portfolio @@ -82,6 +114,26 @@ class PortfolioChartViewModel @Inject constructor( .map { it?.second ?: Currency.USD } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), Currency.USD) + val availablePeriods = portfolio + .map { it?.first?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: defaultPeriods } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), defaultPeriods) + + fun setType(type: PortfolioType) { + if (type == _selectedType.value) { + return + } + _selectedType.value = type + viewState.value = ChartViewState.Loading + } + + fun setChartType(chartType: PortfolioChartType) { + if (chartType == _selectedChartType.value) { + return + } + _selectedChartType.value = chartType + portfolio.value?.first?.let { viewState.value = it.chartState(chartType) } + } + fun setPeriod(period: ChartPeriod) { if (period == selectedPeriod.value) { return @@ -97,5 +149,14 @@ class PortfolioChartViewModel @Inject constructor( } } -private fun PortfolioData.chartValues() = - charts.firstOrNull { it.chartType == PortfolioChartType.Value }?.values.orEmpty() +private fun PortfolioData.chartValues(chartType: PortfolioChartType): List = + (charts.firstOrNull { it.chartType == chartType } ?: charts.firstOrNull())?.values.orEmpty() + +private fun PortfolioData.chartState(chartType: PortfolioChartType): ChartViewState { + val values = chartValues(chartType).map { it.value } + val hasVariation = (values.minOrNull() ?: 0.0) != (values.maxOrNull() ?: 0.0) + return if (values.size < MinChartPoints || !hasVariation) ChartViewState.Empty else ChartViewState.Ready +} + +private fun PortfolioType.displayCurrency(fetchCurrency: Currency): Currency = + if (this == PortfolioType.Perpetuals) Currency.USD else fetchCurrency diff --git a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt index f151767ee8..4a7a5f3461 100644 --- a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt +++ b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.assets.coordinators.GetPortfolioData import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency +import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet import com.gemwallet.android.ui.models.chart.ChartViewState import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod @@ -13,6 +14,7 @@ import com.wallet.core.primitives.PortfolioChartData import com.wallet.core.primitives.PortfolioChartType import com.wallet.core.primitives.PortfolioData import com.wallet.core.primitives.PortfolioStatistic +import com.wallet.core.primitives.PortfolioType import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -21,6 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -43,11 +46,13 @@ class PortfolioChartViewModelTest { private val getCurrentCurrency = mockk(relaxed = true) { every { getCurrency() } returns currencyFlow } + private val observePerpetualWallet = mockk(relaxed = true) private val getPortfolioData = mockk(relaxed = true) @Before fun setUp() { Dispatchers.setMain(testDispatcher) + every { observePerpetualWallet() } returns flowOf(null) } @After @@ -65,7 +70,7 @@ class PortfolioChartViewModelTest { @Test fun `renders chart when portfolio has values`() = runTest(testDispatcher) { - coEvery { getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(10f, 12f, 14f)) + coEvery { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(10f, 12f, 14f)) val viewModel = createViewModel() val uiModel = viewModel.chartUIModel.first { it.chartPoints.size == 3 } @@ -76,19 +81,21 @@ class PortfolioChartViewModelTest { @Test fun `initial request uses all period by default`() = runTest(testDispatcher) { - coEvery { getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(1f, 2f)) + coEvery { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(1f, 2f)) val viewModel = createViewModel() viewModel.chartUIModel.first { it.chartPoints.size == 2 } assertEquals(ChartPeriod.All, viewModel.chartUIState.value.period) - coVerify(exactly = 1) { getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) } + coVerify(exactly = 1) { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } } @Test fun `selecting period updates state and refetches`() = runTest(testDispatcher) { - coEvery { getPortfolioData.getPortfolioData(any(), any()) } returns portfolioData(listOf(1f, 2f)) - coEvery { getPortfolioData.getPortfolioData(ChartPeriod.Month, Currency.USD) } returns portfolioData(listOf(1f, 2f, 3f)) + coEvery { getPortfolioData.getPortfolioData(any(), any(), any()) } returns portfolioData(listOf(1f, 2f)) + coEvery { + getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.Month, Currency.USD) + } returns portfolioData(listOf(1f, 2f, 3f)) val viewModel = createViewModel() backgroundScope.launch { viewModel.chartUIModel.collect {} } @@ -96,14 +103,56 @@ class PortfolioChartViewModelTest { val model = viewModel.chartUIModel.first { it.chartPoints.size == 3 } assertEquals(ChartPeriod.Month, model.period) - coVerify { getPortfolioData.getPortfolioData(ChartPeriod.Month, Currency.USD) } + coVerify { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.Month, Currency.USD) } + } + + @Test + fun `resets period when the selected period is unavailable`() = runTest(testDispatcher) { + val periods = listOf(ChartPeriod.Day, ChartPeriod.Week, ChartPeriod.Month) + coEvery { + getPortfolioData.getPortfolioData(any(), ChartPeriod.All, any()) + } returns portfolioData(listOf(1f, 2f), availablePeriods = periods) + coEvery { + getPortfolioData.getPortfolioData(any(), ChartPeriod.Day, any()) + } returns portfolioData(listOf(1f, 2f, 3f), availablePeriods = periods) + val viewModel = createViewModel() + backgroundScope.launch { viewModel.chartUIModel.collect {} } + + val model = viewModel.chartUIModel.first { it.chartPoints.size == 3 } + + assertEquals(ChartPeriod.Day, model.period) + coVerify { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.Day, Currency.USD) } + } + + @Test + fun `shows error state when the portfolio request fails`() = runTest(testDispatcher) { + coEvery { getPortfolioData.getPortfolioData(any(), any(), any()) } throws IllegalStateException("network down") + val viewModel = createViewModel() + backgroundScope.launch { viewModel.chartUIModel.collect {} } + + val state = viewModel.chartUIState.first { it.viewState != ChartViewState.Loading } + + assertEquals(ChartViewState.Error, state.viewState) + } + + @Test + fun `flat chart without variation is empty`() = runTest(testDispatcher) { + coEvery { + getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) + } returns portfolioData(listOf(5f, 5f, 5f)) + val viewModel = createViewModel() + backgroundScope.launch { viewModel.chartUIModel.collect {} } + + val state = viewModel.chartUIState.first { it.viewState != ChartViewState.Loading } + + assertEquals(ChartViewState.Empty, state.viewState) } @Test fun `exposes all time statistics from portfolio`() = runTest(testDispatcher) { val allTimeHigh = ChartValuePercentage(date = 1L, value = 99f, percentage = 5f) coEvery { - getPortfolioData.getPortfolioData(ChartPeriod.All, Currency.USD) + getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(1f, 2f), statistics = listOf(PortfolioStatistic.AllTimeHigh(allTimeHigh))) val viewModel = createViewModel() @@ -115,6 +164,7 @@ class PortfolioChartViewModelTest { private fun portfolioData( values: List, statistics: List = emptyList(), + availablePeriods: List = emptyList(), ) = PortfolioData( charts = listOf( PortfolioChartData( @@ -125,11 +175,12 @@ class PortfolioChartViewModelTest { ), ), statistics = statistics, - availablePeriods = emptyList(), + availablePeriods = availablePeriods, ) private fun createViewModel() = PortfolioChartViewModel( getCurrentCurrency = getCurrentCurrency, + observePerpetualWallet = observePerpetualWallet, getPortfolioData = getPortfolioData, ).also(viewModels::add) } From 0fbd91c352a4a0efee956dc27a7737fcfa4c6944 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:34:09 +0300 Subject: [PATCH 08/17] Add Portfolio/Perpetuals segmented control and statistics to the scene - Scene title slot shows the Portfolio/Perpetuals segmented control when perpetuals is enabled; actions slot shows the value/PnL chart-type menu; periods are data-driven (viewModel.availablePeriods) - One portfolioStatistics renderer mirrors iOS's single statistics list under an Info section header: the wallet all-time high/low reuse the existing allTimeProperties rows, and the perpetual rows (unrealized/all-time PnL as signed amounts colored by direction, leverage, margin usage with an unsigned percent, volume) reuse the shared property rows and formatters, matching iOS statisticModel --- .../presents/chart/PortfolioChartScene.kt | 94 ++++++++++++++----- .../presents/chart/PortfolioStatistics.kt | 82 ++++++++++++++++ 2 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index eb4a942904..d8272c89cb 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -1,8 +1,15 @@ package com.gemwallet.android.features.asset.presents.chart import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState @@ -15,9 +22,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gemwallet.android.features.asset.viewmodels.chart.models.AllTimeUIModel import com.gemwallet.android.features.asset.viewmodels.chart.models.chartHeader import com.gemwallet.android.features.asset.viewmodels.chart.viewmodels.PortfolioChartViewModel import com.gemwallet.android.ui.R @@ -25,16 +32,8 @@ import com.gemwallet.android.ui.components.chart.ChartStateView import com.gemwallet.android.ui.components.chart.GemLineChart import com.gemwallet.android.ui.components.screen.Scene import com.gemwallet.android.ui.models.chart.ChartViewState -import com.wallet.core.primitives.ChartPeriod -import com.wallet.core.primitives.PortfolioStatistic - -private val portfolioChartPeriods = listOf( - ChartPeriod.Day, - ChartPeriod.Week, - ChartPeriod.Month, - ChartPeriod.Year, - ChartPeriod.All, -) +import com.wallet.core.primitives.PortfolioChartType +import com.wallet.core.primitives.PortfolioType @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -45,11 +44,26 @@ fun PortfolioChartScene( val statistics by viewModel.statistics.collectAsStateWithLifecycle() val currency by viewModel.currency.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() + val showSegmentedControl by viewModel.showSegmentedControl.collectAsStateWithLifecycle() + val selectedChartType by viewModel.selectedChartType.collectAsStateWithLifecycle() + val showChartTypePicker by viewModel.showChartTypePicker.collectAsStateWithLifecycle() val pullToRefreshState = rememberPullToRefreshState() Scene( - title = stringResource(R.string.wallet_portfolio_title), + titleContent = { + if (showSegmentedControl) { + PortfolioTypeSelector(selected = selectedType, onSelect = viewModel::setType) + } else { + Text(stringResource(R.string.wallet_portfolio_title)) + } + }, onClose = onCancel, + actions = { + if (showChartTypePicker) { + ChartTypeSelector(selected = selectedChartType, onSelect = viewModel::setChartType) + } + }, ) { PullToRefreshBox( isRefreshing = isRefreshing, @@ -65,16 +79,52 @@ fun PortfolioChartScene( ) { LazyColumn { item { PortfolioChart(viewModel) } - allTimeProperties(currency, statistics.toAllTimeItems()) + portfolioStatistics(currency, statistics) } } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PortfolioTypeSelector(selected: PortfolioType, onSelect: (PortfolioType) -> Unit) { + SingleChoiceSegmentedButtonRow { + val types = PortfolioType.entries + types.forEachIndexed { index, type -> + SegmentedButton( + selected = type == selected, + onClick = { onSelect(type) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = types.size), + label = { Text(stringResource(type.titleRes())) }, + ) + } + } +} + +@Composable +private fun ChartTypeSelector(selected: PortfolioChartType, onSelect: (PortfolioChartType) -> Unit) { + var expanded by remember { mutableStateOf(false) } + TextButton(onClick = { expanded = true }) { + Text(stringResource(selected.titleRes()), fontWeight = FontWeight.SemiBold) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + PortfolioChartType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(stringResource(type.titleRes())) }, + onClick = { + onSelect(type) + expanded = false + }, + ) + } + } +} + @Composable private fun PortfolioChart(viewModel: PortfolioChartViewModel) { val uiModel by viewModel.chartUIModel.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() + val periods by viewModel.availablePeriods.collectAsStateWithLifecycle() key(state.period) { var selectedIndex by remember { mutableStateOf(null) } @@ -95,7 +145,7 @@ private fun PortfolioChart(viewModel: PortfolioChartViewModel) { header = chartHeader(uiModel, selectedPoint), period = state.period, onPeriodSelect = viewModel::setPeriod, - periods = portfolioChartPeriods, + periods = periods, ) { GemLineChart( points = uiModel.renderPoints, @@ -109,12 +159,12 @@ private fun PortfolioChart(viewModel: PortfolioChartViewModel) { } } -private fun List.toAllTimeItems(): List = mapNotNull { statistic -> - when (statistic) { - is PortfolioStatistic.AllTimeHigh -> - AllTimeUIModel.High(statistic.content.date, statistic.content.value.toDouble(), statistic.content.percentage.toDouble()) - is PortfolioStatistic.AllTimeLow -> - AllTimeUIModel.Low(statistic.content.date, statistic.content.value.toDouble(), statistic.content.percentage.toDouble()) - else -> null - } +private fun PortfolioType.titleRes(): Int = when (this) { + PortfolioType.Wallet -> R.string.wallet_portfolio_title + PortfolioType.Perpetuals -> R.string.perpetuals_title +} + +private fun PortfolioChartType.titleRes(): Int = when (this) { + PortfolioChartType.Value -> R.string.perpetual_value + PortfolioChartType.Pnl -> R.string.perpetual_pnl } diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt new file mode 100644 index 0000000000..0b328b4a13 --- /dev/null +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt @@ -0,0 +1,82 @@ +package com.gemwallet.android.features.asset.presents.chart + +import androidx.annotation.StringRes +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.gemwallet.android.domains.percentage.PercentageFormatterStyle +import com.gemwallet.android.domains.percentage.formatAsPercentage +import com.gemwallet.android.domains.price.toValueDirection +import com.gemwallet.android.features.asset.viewmodels.chart.models.AllTimeUIModel +import com.gemwallet.android.model.CurrencyFormatter +import com.gemwallet.android.model.PriceChangeFormatter +import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.list_item.ListItem +import com.gemwallet.android.ui.components.list_item.SubheaderItem +import com.gemwallet.android.ui.components.list_item.color +import com.gemwallet.android.ui.components.list_item.property.PropertyDataText +import com.gemwallet.android.ui.components.list_item.property.PropertyItem +import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText +import com.gemwallet.android.ui.components.list_item.property.itemsPositioned +import com.gemwallet.android.ui.models.ListPosition +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PortfolioMarginUsage +import com.wallet.core.primitives.PortfolioStatistic + +private const val LeverageFractionDigits = 2 + +internal fun LazyListScope.portfolioStatistics(currency: Currency, statistics: List) { + if (statistics.isEmpty()) return + item { SubheaderItem(R.string.common_info) } + val allTimeItems = statistics.mapNotNull(PortfolioStatistic::asAllTimeUIModel) + if (allTimeItems.isNotEmpty()) { + allTimeProperties(currency, allTimeItems) + } else { + perpetualStatistics(currency, statistics) + } +} + +private fun LazyListScope.perpetualStatistics(currency: Currency, statistics: List) { + val currencyFormatter = CurrencyFormatter(currency = currency) + val priceChangeFormatter = PriceChangeFormatter(currencyFormatter) + itemsPositioned(statistics) { position, statistic -> + when (statistic) { + is PortfolioStatistic.UnrealizedPnl -> + PnlStatRow(R.string.perpetual_unrealized_pnl, statistic.content, priceChangeFormatter, position) + is PortfolioStatistic.AllTimePnl -> + PnlStatRow(R.string.perpetual_all_time_pnl, statistic.content, priceChangeFormatter, position) + is PortfolioStatistic.AccountLeverage -> + PropertyItem(R.string.perpetual_account_leverage, statistic.content.leverageText(), listPosition = position) + is PortfolioStatistic.MarginUsage -> + PropertyItem(R.string.perpetual_margin_usage, statistic.content.marginText(currencyFormatter), listPosition = position) + is PortfolioStatistic.Volume -> + PropertyItem(R.string.perpetual_volume, currencyFormatter.string(statistic.content), listPosition = position) + is PortfolioStatistic.AllTimeHigh, is PortfolioStatistic.AllTimeLow -> Unit + } + } +} + +@Composable +private fun PnlStatRow( + @StringRes title: Int, + value: Double, + priceChangeFormatter: PriceChangeFormatter, + listPosition: ListPosition, +) { + ListItem( + listPosition = listPosition, + title = { PropertyTitleText(text = stringResource(title)) }, + trailing = { PropertyDataText(priceChangeFormatter.string(value), color = value.toValueDirection().color()) }, + ) +} + +private fun PortfolioStatistic.asAllTimeUIModel(): AllTimeUIModel? = when (this) { + is PortfolioStatistic.AllTimeHigh -> AllTimeUIModel.High(content.date, content.value.toDouble(), content.percentage.toDouble()) + is PortfolioStatistic.AllTimeLow -> AllTimeUIModel.Low(content.date, content.value.toDouble(), content.percentage.toDouble()) + else -> null +} + +private fun Double.leverageText(): String = "%.${LeverageFractionDigits}fx".format(this) + +private fun PortfolioMarginUsage.marginText(formatter: CurrencyFormatter): String = + "${formatter.string(accountValue * usage)} (${(usage * 100).formatAsPercentage(PercentageFormatterStyle.PercentSignLess)})" From 37c60937672b5fa512b780f9dc5525b81e3be172 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:22:32 +0300 Subject: [PATCH 09/17] Match iOS portfolio chart header and hide stats while loading Header: build a priceChange-style header (ChartValueType.PriceChange) like iOS ChartValuesViewModel instead of reusing the asset price header. It shows the absolute value as the big header value, the signed change amount colored by the amount's sign (so a negative perpetuals PnL reads red), and the percentage as an unsigned value in parentheses that is hidden when there is no header value (perpetuals PnL). showHeaderValue follows iOS: wallet, or the value chart type. The change amount uses the raw first point as the base so the perpetuals PnL change equals the full PnL, matching iOS. Footer: render the statistics section only when the chart is loaded (ready or empty), mirroring iOS which sources statistics from the data state, rather than leaving the previous type's rows visible during a refetch. The header-model change is additive: the asset price chart passes no type (defaults to Price) and renders exactly as before. --- .../presents/chart/PortfolioChartScene.kt | 10 +++-- .../viewmodels/chart/models/ChartUIModel.kt | 23 +++++++++++ .../viewmodels/PortfolioChartViewModel.kt | 4 ++ .../ui/models/chart/ChartHeaderUIModel.kt | 26 +++++++++++-- .../ui/models/chart/ChartHeaderUIModelTest.kt | 39 +++++++++++++++++++ .../ui/components/chart/ChartHeader.kt | 36 +++++++++++++---- 6 files changed, 123 insertions(+), 15 deletions(-) diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index d8272c89cb..1319058023 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gemwallet.android.features.asset.viewmodels.chart.models.chartHeader +import com.gemwallet.android.features.asset.viewmodels.chart.models.portfolioChartHeader import com.gemwallet.android.features.asset.viewmodels.chart.viewmodels.PortfolioChartViewModel import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.chart.ChartStateView @@ -48,6 +48,7 @@ fun PortfolioChartScene( val showSegmentedControl by viewModel.showSegmentedControl.collectAsStateWithLifecycle() val selectedChartType by viewModel.selectedChartType.collectAsStateWithLifecycle() val showChartTypePicker by viewModel.showChartTypePicker.collectAsStateWithLifecycle() + val state by viewModel.chartUIState.collectAsStateWithLifecycle() val pullToRefreshState = rememberPullToRefreshState() Scene( @@ -79,7 +80,9 @@ fun PortfolioChartScene( ) { LazyColumn { item { PortfolioChart(viewModel) } - portfolioStatistics(currency, statistics) + if (state.viewState == ChartViewState.Ready || state.viewState == ChartViewState.Empty) { + portfolioStatistics(currency, statistics) + } } } } @@ -125,6 +128,7 @@ private fun PortfolioChart(viewModel: PortfolioChartViewModel) { val uiModel by viewModel.chartUIModel.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() val periods by viewModel.availablePeriods.collectAsStateWithLifecycle() + val showHeaderValue by viewModel.showHeaderValue.collectAsStateWithLifecycle() key(state.period) { var selectedIndex by remember { mutableStateOf(null) } @@ -142,7 +146,7 @@ private fun PortfolioChart(viewModel: PortfolioChartViewModel) { ChartStateView( state = displayState, - header = chartHeader(uiModel, selectedPoint), + header = portfolioChartHeader(uiModel, selectedPoint, showHeaderValue), period = state.period, onPeriodSelect = viewModel::setPeriod, periods = periods, diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt index 82ff150f68..1ba3e00b77 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt @@ -5,8 +5,10 @@ import com.gemwallet.android.ext.secondsToMillis import com.gemwallet.android.math.getRelativeDate import com.gemwallet.android.model.AssetPriceInfo import com.gemwallet.android.model.CurrencyFormatter +import com.gemwallet.android.model.PriceChangeFormatter import com.gemwallet.android.ui.components.chart.ChartPoint import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel +import com.gemwallet.android.ui.models.chart.ChartValueType import com.gemwallet.android.ui.models.chart.ChartViewState import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod @@ -18,6 +20,7 @@ data class ChartUIModel( val currentPoint: PricePoint? = null, val chartPoints: List = emptyList(), internal val priceFormatter: (Double) -> String = { "" }, + internal val priceChangeFormatter: (Double) -> String = { "" }, ) { val renderPoints: List by lazy { chartPoints.mapIndexed { index, point -> ChartPoint(x = index.toFloat(), y = point.y) } @@ -95,6 +98,7 @@ internal fun ChartUIModel.Companion.from( period = period, chartPoints = points, priceFormatter = currencyFormatter::string, + priceChangeFormatter = PriceChangeFormatter(currencyFormatter)::string, ) } @@ -108,3 +112,22 @@ fun chartHeader(uiModel: ChartUIModel, selectedPoint: PricePoint?): ChartHeaderU dateFormatter = ::getRelativeDate, ) } + +fun portfolioChartHeader( + uiModel: ChartUIModel, + selectedPoint: PricePoint?, + showHeaderValue: Boolean, +): ChartHeaderUIModel? { + val target = selectedPoint ?: uiModel.chartPoints.lastOrNull() ?: return null + val base = uiModel.chartPoints.firstOrNull()?.price ?: 0.0 + return ChartHeaderUIModel.build( + price = target.price - base, + priceChangePercentage = target.priceChangePercentage, + type = ChartValueType.PriceChange, + timestamp = selectedPoint?.timestamp, + headerValue = if (showHeaderValue) target.price else null, + priceFormatter = uiModel.priceFormatter, + priceChangeFormatter = uiModel.priceChangeFormatter, + dateFormatter = ::getRelativeDate, + ) +} diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index 3b69679c69..b2a853610e 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -67,6 +67,10 @@ class PortfolioChartViewModel @Inject constructor( .map { it == PortfolioType.Perpetuals } .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val showHeaderValue = combine(selectedType, selectedChartType) { type, chartType -> + type == PortfolioType.Wallet || chartType == PortfolioChartType.Value + }.stateIn(viewModelScope, SharingStarted.Eagerly, true) + val chartUIState = combine(selectedPeriod, viewState) { period, viewState -> ChartUIModel.State(period = period, viewState = viewState) }.stateIn(viewModelScope, SharingStarted.Eagerly, ChartUIModel.State()) diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModel.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModel.kt index 05d8be80d6..05f7364ad2 100644 --- a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModel.kt +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModel.kt @@ -1,30 +1,48 @@ package com.gemwallet.android.ui.models.chart +import com.gemwallet.android.domains.percentage.PercentageFormatterStyle import com.gemwallet.android.domains.percentage.formatAsPercentage import com.gemwallet.android.domains.price.ValueDirection import com.gemwallet.android.domains.price.toValueDirection +enum class ChartValueType { Price, PriceChange } + data class ChartHeaderUIModel( val priceText: String, - val changeText: String, + val changeText: String?, val direction: ValueDirection, val dateText: String?, val headerValueText: String? = null, + val type: ChartValueType = ChartValueType.Price, ) { companion object { fun build( price: Double, priceChangePercentage: Double, + type: ChartValueType = ChartValueType.Price, timestamp: Long? = null, headerValue: Double? = null, priceFormatter: (Double) -> String, + priceChangeFormatter: (Double) -> String = priceFormatter, dateFormatter: (Long) -> String = { "" }, ): ChartHeaderUIModel = ChartHeaderUIModel( - priceText = priceFormatter(price), - changeText = priceChangePercentage.formatAsPercentage(), - direction = priceChangePercentage.toValueDirection(), + priceText = when (type) { + ChartValueType.Price -> priceFormatter(price) + ChartValueType.PriceChange -> priceChangeFormatter(price) + }, + changeText = when (type) { + ChartValueType.Price -> priceChangePercentage.formatAsPercentage() + ChartValueType.PriceChange -> + if (headerValue != null && priceChangePercentage != 0.0) { + "(${priceChangePercentage.formatAsPercentage(PercentageFormatterStyle.PercentSignLess)})" + } else { + null + } + }, + direction = (if (type == ChartValueType.PriceChange) price else priceChangePercentage).toValueDirection(), dateText = timestamp?.let(dateFormatter), headerValueText = headerValue?.let(priceFormatter), + type = type, ) } } diff --git a/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModelTest.kt b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModelTest.kt index 52f497c026..c336cdea85 100644 --- a/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModelTest.kt +++ b/android/ui-models/src/test/kotlin/com/gemwallet/android/ui/models/chart/ChartHeaderUIModelTest.kt @@ -1,8 +1,12 @@ package com.gemwallet.android.ui.models.chart import com.gemwallet.android.domains.price.ValueDirection +import com.gemwallet.android.model.CurrencyFormatter +import com.gemwallet.android.model.PriceChangeFormatter +import com.wallet.core.primitives.Currency import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.Locale @@ -15,6 +19,8 @@ class ChartHeaderUIModelTest { } private val formatter: (Double) -> String = { "$%.2f".format(it) } + private val changeFormatter: (Double) -> String = + PriceChangeFormatter(CurrencyFormatter(type = CurrencyFormatter.Type.Fiat, currency = Currency.USD, locale = Locale.US))::string @Test fun buildPopulatesFromRawValues() { @@ -54,4 +60,37 @@ class ChartHeaderUIModelTest { assertEquals("$1500.00", model.headerValueText) assertEquals(ValueDirection.None, model.direction) } + + @Test + fun buildPriceChangeShowsSignedAmountValueAndParenthesizedPercent() { + val model = ChartHeaderUIModel.build( + price = 90.0, + priceChangePercentage = 12.0, + type = ChartValueType.PriceChange, + headerValue = 190.0, + priceFormatter = formatter, + priceChangeFormatter = changeFormatter, + ) + assertEquals("+$90.00", model.priceText) + assertEquals("$190.00", model.headerValueText) + assertEquals(ChartValueType.PriceChange, model.type) + assertEquals(ValueDirection.Up, model.direction) + assertTrue(model.changeText!!.startsWith("(") && model.changeText!!.endsWith(")")) + } + + @Test + fun buildPriceChangeOmitsPercentWhenNoHeaderValue() { + val model = ChartHeaderUIModel.build( + price = -40.0, + priceChangePercentage = -8.0, + type = ChartValueType.PriceChange, + priceFormatter = formatter, + priceChangeFormatter = changeFormatter, + ) + assertEquals("-$40.00", model.priceText) + assertNull(model.changeText) + assertNull(model.headerValueText) + assertEquals(ChartValueType.PriceChange, model.type) + assertEquals(ValueDirection.Down, model.direction) + } } diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartHeader.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartHeader.kt index 0961ca8c8a..cecb6459f6 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartHeader.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartHeader.kt @@ -12,8 +12,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import com.gemwallet.android.ui.components.list_item.color import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel +import com.gemwallet.android.ui.models.chart.ChartValueType import com.gemwallet.android.ui.theme.paddingDefault import com.gemwallet.android.ui.theme.space8 @@ -26,18 +29,35 @@ fun ChartHeader( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row(verticalAlignment = Alignment.CenterVertically) { + model.headerValueText?.let { headerValue -> Text( - text = model.priceText, - style = MaterialTheme.typography.headlineSmall, + text = headerValue, + style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, ) - Spacer(modifier = Modifier.width(space8)) + } + val changeStyle = if (model.headerValueText != null) { + MaterialTheme.typography.titleMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.Medium) + } else { + MaterialTheme.typography.headlineSmall + } + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = model.changeText, - style = MaterialTheme.typography.bodyLarge, - color = model.direction.color(), + text = model.priceText, + style = changeStyle, + color = when (model.type) { + ChartValueType.Price -> MaterialTheme.colorScheme.onSurface + ChartValueType.PriceChange -> model.direction.color() + }, ) + model.changeText?.let { change -> + Spacer(modifier = Modifier.width(space8)) + Text( + text = change, + style = if (model.headerValueText != null) changeStyle else MaterialTheme.typography.bodyLarge, + color = model.direction.color(), + ) + } } Box( modifier = Modifier.height(paddingDefault), @@ -46,7 +66,7 @@ fun ChartHeader( Text( text = model.dateText.orEmpty(), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.secondary, ) } } From 2cf182ef070b2bb66c454dc116041cfecd51042e Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:51:27 +0300 Subject: [PATCH 10/17] Open perpetuals portfolio from the perpetuals balance Mirror iOS: tapping the balance on the perpetuals screen opens the portfolio with the Perpetuals type preselected (PerpetualsScene balanceActionType -> .portfolio(.perpetuals)), while the wallet balance subtitle keeps opening it on Wallet. - PortfolioChartRoute carries a PortfolioType (default Wallet) passed to the ViewModel via the Type route argument, like WalletPhraseRoute - PerpetualMarketScene dispatches OpenPortfolio from AmountListHead onClick through the screen's sealed action - PortfolioChartViewModel takes the initial type via SavedStateHandle with the same two-constructor pattern as ChartViewModel - Scene title follows the selected type when the segmented control is hidden, matching iOS typeTitle(for:) --- .../android/ui/navigation/RootRoute.kt | 3 ++- .../android/ui/navigation/WalletNavGraph.kt | 2 ++ .../android/ui/navigation/routes/Perpetual.kt | 2 ++ .../android/ui/navigation/routes/Portfolio.kt | 9 ++++++-- .../presents/chart/PortfolioChartScene.kt | 2 +- .../viewmodels/PortfolioChartViewModel.kt | 23 +++++++++++++++---- .../viewmodels/PortfolioRouteArguments.kt | 8 +++++++ .../viewmodels/PortfolioChartViewModelTest.kt | 17 +++++++++++++- .../views/market/PerpetualMarketAction.kt | 1 + .../views/market/PerpetualMarketNavScreen.kt | 2 ++ .../views/market/PerpetualMarketScene.kt | 3 ++- 11 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioRouteArguments.kt diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt index 3329d47ef3..602f5b65d9 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt @@ -78,6 +78,7 @@ import com.gemwallet.android.ui.navigation.routes.WalletsRoute import com.gemwallet.android.ext.toIdentifier import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.NFTAssetId +import com.wallet.core.primitives.PortfolioType import com.wallet.core.primitives.TransactionId import com.wallet.core.primitives.WalletId import com.wallet.core.primitives.WalletType @@ -190,7 +191,7 @@ class WalletNavigator( fun openAddAsset() = push(AddAssetRoute) fun openAsset(assetId: AssetId) = push(AssetRoute(assetId)) fun openAssetChart(assetId: AssetId) = push(AssetChartRoute(assetId)) - fun openPortfolioChart() = push(PortfolioChartRoute) + fun openPortfolioChart(type: PortfolioType = PortfolioType.Wallet) = push(PortfolioChartRoute(type)) fun openTransaction(transactionId: TransactionId) = push(TransactionDetailsRoute(transactionId)) fun openBridgeConnections() = push(BridgeConnectionsRoute) fun openBridgeConnectionDetails(connectionId: String) = push(BridgeConnectionDetailsRoute(connectionId)) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt index 876566f808..b3a7ca3deb 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt @@ -57,6 +57,7 @@ import com.gemwallet.android.ui.navigation.routes.transactionDetailsScreen import com.gemwallet.android.ui.navigation.routes.walletScreen import com.gemwallet.android.ui.navigation.routes.walletSearchScreen import com.gemwallet.android.ui.navigation.routes.walletsScreen +import com.wallet.core.primitives.PortfolioType import com.wallet.core.primitives.WalletId @OptIn(ExperimentalComposeUiApi::class) @@ -295,6 +296,7 @@ fun WalletNavGraph( perpetualScreen( onOpenPerpetualDetails = navigator::openPerpetualDetails, + onOpenPortfolio = { navigator.openPortfolioChart(PortfolioType.Perpetuals) }, amountAction = AmountTransactionAction(navigator::openAmount), confirmAction = ConfirmTransactionAction(navigator::openConfirm), onCancel = onCancel, diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt index bce4145184..274390fa30 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Perpetual.kt @@ -22,6 +22,7 @@ data class PerpetualPositionRoute(val assetId: AssetId) : NavKey fun EntryProviderScope.perpetualScreen( onCancel: () -> Unit, onOpenPerpetualDetails: AssetIdAction, + onOpenPortfolio: () -> Unit, amountAction: AmountTransactionAction, confirmAction: ConfirmTransactionAction, onTransaction: (TransactionId) -> Unit, @@ -29,6 +30,7 @@ fun EntryProviderScope.perpetualScreen( entry { PerpetualMarketNavScreen( onOpenPerpetualDetails = onOpenPerpetualDetails, + onOpenPortfolio = onOpenPortfolio, amountAction = amountAction, onCancel = onCancel, ) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt index 0cfca69b17..618c316f3d 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/routes/Portfolio.kt @@ -3,15 +3,20 @@ package com.gemwallet.android.ui.navigation.routes import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.gemwallet.android.features.asset.presents.chart.PortfolioChartScene +import com.gemwallet.android.ui.models.navigation.RouteArgument +import com.gemwallet.android.ui.navigation.routeArguments +import com.wallet.core.primitives.PortfolioType import kotlinx.serialization.Serializable @Serializable -data object PortfolioChartRoute : NavKey +data class PortfolioChartRoute(val type: PortfolioType = PortfolioType.Wallet) : NavKey fun EntryProviderScope.portfolioChartScreen( onCancel: () -> Unit, ) { - entry { + entry( + metadata = { key -> routeArguments(RouteArgument.Type to key.type) }, + ) { PortfolioChartScene(onCancel = onCancel) } } diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index 1319058023..8acdae9a27 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -56,7 +56,7 @@ fun PortfolioChartScene( if (showSegmentedControl) { PortfolioTypeSelector(selected = selectedType, onSelect = viewModel::setType) } else { - Text(stringResource(R.string.wallet_portfolio_title)) + Text(stringResource(selectedType.titleRes())) } }, onClose = onCancel, diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index b2a853610e..865e27a987 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -1,5 +1,6 @@ package com.gemwallet.android.features.asset.viewmodels.chart.viewmodels +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.assets.coordinators.GetPortfolioData @@ -43,12 +44,13 @@ private val defaultPeriods = listOf( @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel -class PortfolioChartViewModel @Inject constructor( +class PortfolioChartViewModel internal constructor( getCurrentCurrency: GetCurrentCurrency, observePerpetualWallet: ObservePerpetualWallet, private val getPortfolioData: GetPortfolioData, + initialType: PortfolioType, ) : ViewModel() { - private val _selectedType = MutableStateFlow(PortfolioType.Wallet) + private val _selectedType = MutableStateFlow(initialType) val selectedType = _selectedType.asStateFlow() private val _selectedChartType = MutableStateFlow(PortfolioChartType.Pnl) @@ -65,11 +67,11 @@ class PortfolioChartViewModel @Inject constructor( val showChartTypePicker = selectedType .map { it == PortfolioType.Perpetuals } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + .stateIn(viewModelScope, SharingStarted.Eagerly, initialType == PortfolioType.Perpetuals) val showHeaderValue = combine(selectedType, selectedChartType) { type, chartType -> type == PortfolioType.Wallet || chartType == PortfolioChartType.Value - }.stateIn(viewModelScope, SharingStarted.Eagerly, true) + }.stateIn(viewModelScope, SharingStarted.Eagerly, initialType == PortfolioType.Wallet) val chartUIState = combine(selectedPeriod, viewState) { period, viewState -> ChartUIModel.State(period = period, viewState = viewState) @@ -151,6 +153,19 @@ class PortfolioChartViewModel @Inject constructor( viewState.value = ChartViewState.Loading refreshTrigger.value = refreshTrigger.value + 1 } + + @Inject + constructor( + getCurrentCurrency: GetCurrentCurrency, + observePerpetualWallet: ObservePerpetualWallet, + getPortfolioData: GetPortfolioData, + savedStateHandle: SavedStateHandle, + ) : this( + getCurrentCurrency = getCurrentCurrency, + observePerpetualWallet = observePerpetualWallet, + getPortfolioData = getPortfolioData, + initialType = savedStateHandle.portfolioType(), + ) } private fun PortfolioData.chartValues(chartType: PortfolioChartType): List = diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioRouteArguments.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioRouteArguments.kt new file mode 100644 index 0000000000..6e62eed3e4 --- /dev/null +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioRouteArguments.kt @@ -0,0 +1,8 @@ +package com.gemwallet.android.features.asset.viewmodels.chart.viewmodels + +import androidx.lifecycle.SavedStateHandle +import com.gemwallet.android.ui.models.navigation.RouteArgument +import com.wallet.core.primitives.PortfolioType + +internal fun SavedStateHandle.portfolioType(): PortfolioType = + get(RouteArgument.Type.key) ?: PortfolioType.Wallet diff --git a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt index 4a7a5f3461..3c3acd26c6 100644 --- a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt +++ b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt @@ -124,6 +124,20 @@ class PortfolioChartViewModelTest { coVerify { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.Day, Currency.USD) } } + @Test + fun `starts on perpetuals when opened with perpetuals type`() = runTest(testDispatcher) { + coEvery { + getPortfolioData.getPortfolioData(PortfolioType.Perpetuals, ChartPeriod.All, Currency.USD) + } returns portfolioData(listOf(1f, 2f)) + val viewModel = createViewModel(initialType = PortfolioType.Perpetuals) + backgroundScope.launch { viewModel.chartUIModel.collect {} } + + viewModel.chartUIModel.first { it.chartPoints.size == 2 } + + assertEquals(PortfolioType.Perpetuals, viewModel.selectedType.value) + coVerify(exactly = 0) { getPortfolioData.getPortfolioData(PortfolioType.Wallet, any(), any()) } + } + @Test fun `shows error state when the portfolio request fails`() = runTest(testDispatcher) { coEvery { getPortfolioData.getPortfolioData(any(), any(), any()) } throws IllegalStateException("network down") @@ -178,9 +192,10 @@ class PortfolioChartViewModelTest { availablePeriods = availablePeriods, ) - private fun createViewModel() = PortfolioChartViewModel( + private fun createViewModel(initialType: PortfolioType = PortfolioType.Wallet) = PortfolioChartViewModel( getCurrentCurrency = getCurrentCurrency, observePerpetualWallet = observePerpetualWallet, getPortfolioData = getPortfolioData, + initialType = initialType, ).also(viewModels::add) } diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt index fc71aa8938..3d30575082 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketAction.kt @@ -7,6 +7,7 @@ internal sealed interface PerpetualMarketAction { data object Refresh : PerpetualMarketAction data object Withdraw : PerpetualMarketAction data object Deposit : PerpetualMarketAction + data object OpenPortfolio : PerpetualMarketAction data object Close : PerpetualMarketAction data class TogglePin(val perpetualId: PerpetualId) : PerpetualMarketAction data class OpenPerpetual(val assetId: AssetId) : PerpetualMarketAction diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt index f2aeac8d4c..764e758ace 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketNavScreen.kt @@ -22,6 +22,7 @@ import com.gemwallet.android.ui.models.actions.AssetIdAction fun PerpetualMarketNavScreen( onCancel: () -> Unit, onOpenPerpetualDetails: AssetIdAction, + onOpenPortfolio: () -> Unit, amountAction: AmountTransactionAction, viewModel: PerpetualMarketViewModel = hiltViewModel(), recentsViewModel: RecentsSheetViewModel = hiltViewModel(), @@ -59,6 +60,7 @@ fun PerpetualMarketNavScreen( PerpetualMarketAction.Close -> onCancel() PerpetualMarketAction.Withdraw -> amountAction(AmountParams.Withdraw(HypercoreUSDC.id)) PerpetualMarketAction.Deposit -> amountAction(AmountParams.Deposit(PerpetualConfig.depositAssetId)) + PerpetualMarketAction.OpenPortfolio -> onOpenPortfolio() is PerpetualMarketAction.TogglePin -> viewModel.onTogglePin(action.perpetualId) is PerpetualMarketAction.OpenPerpetual -> { onOpenPerpetualDetails(action.assetId) diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt index 10a91b4aff..329395aea1 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/market/PerpetualMarketScene.kt @@ -143,7 +143,8 @@ internal fun PerpetualMarketScene( equivalent = stringResource( R.string.wallet_available_balance, balance.available - ) + ), + onClick = { onAction(PerpetualMarketAction.OpenPortfolio) }, ) { MarketHeadActions( onWithdraw = { onAction(PerpetualMarketAction.Withdraw) }, From 081da23be834180a68e9526b0711ff75ec17b4d3 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:06:34 +0300 Subject: [PATCH 11/17] Reuse one chart section for the asset and portfolio charts The portfolio chart duplicated the asset Chart composable body (period-keyed selection state, the loading fallback while a new period's data arrives, and the ChartStateView/GemLineChart wiring). Extract that block as ChartSection with the periods and the header builder as parameters; Chart and PortfolioChart are now thin wrappers that collect their ViewModel and choose the header. --- .../features/asset/presents/chart/Chart.kt | 23 ++++++++++- .../presents/chart/PortfolioChartScene.kt | 41 +++---------------- 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt index fb8d67425d..f91ca6bac9 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt @@ -9,18 +9,36 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel import com.gemwallet.android.features.asset.viewmodels.chart.models.PricePoint import com.gemwallet.android.features.asset.viewmodels.chart.models.chartHeader import com.gemwallet.android.features.asset.viewmodels.chart.viewmodels.ChartViewModel import com.gemwallet.android.ui.components.chart.ChartStateView import com.gemwallet.android.ui.components.chart.GemLineChart +import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel import com.gemwallet.android.ui.models.chart.ChartViewState +import com.wallet.core.primitives.ChartPeriod @Composable fun Chart(viewModel: ChartViewModel = hiltViewModel()) { val uiModel by viewModel.chartUIModel.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() + ChartSection( + uiModel = uiModel, + state = state, + onPeriodSelect = viewModel::setPeriod, + ) { selectedPoint -> chartHeader(uiModel, selectedPoint) } +} + +@Composable +internal fun ChartSection( + uiModel: ChartUIModel, + state: ChartUIModel.State, + onPeriodSelect: (ChartPeriod) -> Unit, + periods: List = ChartPeriod.entries, + header: (PricePoint?) -> ChartHeaderUIModel?, +) { key(state.period) { var selectedIndex by remember { mutableStateOf(null) } @@ -37,9 +55,10 @@ fun Chart(viewModel: ChartViewModel = hiltViewModel()) { ChartStateView( state = displayState, - header = chartHeader(uiModel, selectedPoint), + header = header(selectedPoint), period = state.period, - onPeriodSelect = viewModel::setPeriod, + onPeriodSelect = onPeriodSelect, + periods = periods, ) { GemLineChart( points = uiModel.renderPoints, diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index 8acdae9a27..443f380058 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -15,7 +14,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -28,8 +26,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.features.asset.viewmodels.chart.models.portfolioChartHeader import com.gemwallet.android.features.asset.viewmodels.chart.viewmodels.PortfolioChartViewModel import com.gemwallet.android.ui.R -import com.gemwallet.android.ui.components.chart.ChartStateView -import com.gemwallet.android.ui.components.chart.GemLineChart import com.gemwallet.android.ui.components.screen.Scene import com.gemwallet.android.ui.models.chart.ChartViewState import com.wallet.core.primitives.PortfolioChartType @@ -130,37 +126,12 @@ private fun PortfolioChart(viewModel: PortfolioChartViewModel) { val periods by viewModel.availablePeriods.collectAsStateWithLifecycle() val showHeaderValue by viewModel.showHeaderValue.collectAsStateWithLifecycle() - key(state.period) { - var selectedIndex by remember { mutableStateOf(null) } - - val displayState = when { - state.period != uiModel.period -> ChartViewState.Loading - else -> state.viewState - } - val chartPoints = uiModel.chartPoints - val selectedPoint = if (displayState == ChartViewState.Ready) { - selectedIndex?.let { chartPoints.getOrNull(it) } - } else { - null - } - - ChartStateView( - state = displayState, - header = portfolioChartHeader(uiModel, selectedPoint, showHeaderValue), - period = state.period, - onPeriodSelect = viewModel::setPeriod, - periods = periods, - ) { - GemLineChart( - points = uiModel.renderPoints, - lineColor = MaterialTheme.colorScheme.primary, - selectedIndex = selectedIndex, - onSelectionChanged = { selectedIndex = it }, - minLabel = uiModel.minLabel, - maxLabel = uiModel.maxLabel, - ) - } - } + ChartSection( + uiModel = uiModel, + state = state, + onPeriodSelect = viewModel::setPeriod, + periods = periods, + ) { selectedPoint -> portfolioChartHeader(uiModel, selectedPoint, showHeaderValue) } } private fun PortfolioType.titleRes(): Int = when (this) { From 9439a14373a7a00f7ebbff024a30d4468761a47d Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:15:03 +0300 Subject: [PATCH 12/17] Use the shared TabsBar and top bar menu style for the portfolio controls The Portfolio/Perpetuals switch was the app's only Material3 SegmentedButton; replace it with the shared TabsBar the buy/sell switch already uses in its scene title. The chart-type menu label matches the wallet selector in AssetsTopBar: onSurface text with an ExpandMore affordance instead of a bare primary-colored label. --- .../presents/chart/PortfolioChartScene.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index 443f380058..7bb2277d0e 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -1,12 +1,12 @@ package com.gemwallet.android.features.asset.presents.chart +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -26,7 +26,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gemwallet.android.features.asset.viewmodels.chart.models.portfolioChartHeader import com.gemwallet.android.features.asset.viewmodels.chart.viewmodels.PortfolioChartViewModel import com.gemwallet.android.ui.R +import com.gemwallet.android.ui.components.TabsBar import com.gemwallet.android.ui.components.screen.Scene +import com.gemwallet.android.ui.icons.AppIcons import com.gemwallet.android.ui.models.chart.ChartViewState import com.wallet.core.primitives.PortfolioChartType import com.wallet.core.primitives.PortfolioType @@ -84,19 +86,10 @@ fun PortfolioChartScene( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PortfolioTypeSelector(selected: PortfolioType, onSelect: (PortfolioType) -> Unit) { - SingleChoiceSegmentedButtonRow { - val types = PortfolioType.entries - types.forEachIndexed { index, type -> - SegmentedButton( - selected = type == selected, - onClick = { onSelect(type) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = types.size), - label = { Text(stringResource(type.titleRes())) }, - ) - } + TabsBar(PortfolioType.entries, selected, onSelect) { type -> + Text(stringResource(type.titleRes())) } } @@ -104,7 +97,18 @@ private fun PortfolioTypeSelector(selected: PortfolioType, onSelect: (PortfolioT private fun ChartTypeSelector(selected: PortfolioChartType, onSelect: (PortfolioChartType) -> Unit) { var expanded by remember { mutableStateOf(false) } TextButton(onClick = { expanded = true }) { - Text(stringResource(selected.titleRes()), fontWeight = FontWeight.SemiBold) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(selected.titleRes()), + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Icon( + imageVector = AppIcons.ExpandMore, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "select_chart_type", + ) + } } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { PortfolioChartType.entries.forEach { type -> From f06b258cfbc360e114122f06cf98d5c8c97f9862 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:23:48 +0300 Subject: [PATCH 13/17] Derive the portfolio chart state from its tagged fetch Review findings: viewState was a MutableStateFlow written from five call sites, with the ready/empty rule computed from a stale _selectedChartType read inside mapLatest and re-derived in setChartType from a lagging portfolio.value; the fetch result also carried no request identity, so a type switch could pair Ready with the previous type's points, and the cancelled block's finally hid the refresh indicator while the replacing fetch was still loading. The fetch now emits tagged with its request (type + period): the chart state, model, and statistics derive reactively from the tag matching the current selection, the period reset uses compareAndSet so a cancelled fetch cannot override a newer user selection, and the refresh flag clears only when a fetch actually finishes. showChartTypePicker/showHeaderValue StateFlows are gone - the picker derives from selectedType at the call site and the header value is baked into ChartUIModel like iOS ChartValuesViewModel.headerValue, which also removes their duplicated initial-value predicates. The chart percentage base now uses the raw first point like iOS ChartValuesViewModel.priceChange (was first non-zero). --- .../presents/chart/PortfolioChartScene.kt | 6 +- .../viewmodels/chart/models/ChartUIModel.kt | 13 ++- .../viewmodels/PortfolioChartViewModel.kt | 102 +++++++++--------- .../viewmodels/PortfolioChartViewModelTest.kt | 4 +- 4 files changed, 63 insertions(+), 62 deletions(-) diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index 7bb2277d0e..3a75374c93 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -45,7 +45,6 @@ fun PortfolioChartScene( val selectedType by viewModel.selectedType.collectAsStateWithLifecycle() val showSegmentedControl by viewModel.showSegmentedControl.collectAsStateWithLifecycle() val selectedChartType by viewModel.selectedChartType.collectAsStateWithLifecycle() - val showChartTypePicker by viewModel.showChartTypePicker.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() val pullToRefreshState = rememberPullToRefreshState() @@ -59,7 +58,7 @@ fun PortfolioChartScene( }, onClose = onCancel, actions = { - if (showChartTypePicker) { + if (selectedType == PortfolioType.Perpetuals) { ChartTypeSelector(selected = selectedChartType, onSelect = viewModel::setChartType) } }, @@ -128,14 +127,13 @@ private fun PortfolioChart(viewModel: PortfolioChartViewModel) { val uiModel by viewModel.chartUIModel.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() val periods by viewModel.availablePeriods.collectAsStateWithLifecycle() - val showHeaderValue by viewModel.showHeaderValue.collectAsStateWithLifecycle() ChartSection( uiModel = uiModel, state = state, onPeriodSelect = viewModel::setPeriod, periods = periods, - ) { selectedPoint -> portfolioChartHeader(uiModel, selectedPoint, showHeaderValue) } + ) { selectedPoint -> portfolioChartHeader(uiModel, selectedPoint) } } private fun PortfolioType.titleRes(): Int = when (this) { diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt index 1ba3e00b77..168958cee2 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt @@ -21,6 +21,7 @@ data class ChartUIModel( val chartPoints: List = emptyList(), internal val priceFormatter: (Double) -> String = { "" }, internal val priceChangeFormatter: (Double) -> String = { "" }, + internal val showHeaderValue: Boolean = true, ) { val renderPoints: List by lazy { chartPoints.mapIndexed { index, point -> ChartPoint(x = index.toFloat(), y = point.y) } @@ -83,8 +84,9 @@ internal fun ChartUIModel.Companion.from( values: List, period: ChartPeriod, currency: Currency, + showHeaderValue: Boolean, ): ChartUIModel { - val basePrice = values.firstOrNull { it.value != 0.0 }?.value ?: 0.0 + val basePrice = values.firstOrNull()?.value ?: 0.0 val currencyFormatter = CurrencyFormatter(currency = currency) val points = values.map { value -> PricePoint( @@ -99,6 +101,7 @@ internal fun ChartUIModel.Companion.from( chartPoints = points, priceFormatter = currencyFormatter::string, priceChangeFormatter = PriceChangeFormatter(currencyFormatter)::string, + showHeaderValue = showHeaderValue, ) } @@ -113,11 +116,7 @@ fun chartHeader(uiModel: ChartUIModel, selectedPoint: PricePoint?): ChartHeaderU ) } -fun portfolioChartHeader( - uiModel: ChartUIModel, - selectedPoint: PricePoint?, - showHeaderValue: Boolean, -): ChartHeaderUIModel? { +fun portfolioChartHeader(uiModel: ChartUIModel, selectedPoint: PricePoint?): ChartHeaderUIModel? { val target = selectedPoint ?: uiModel.chartPoints.lastOrNull() ?: return null val base = uiModel.chartPoints.firstOrNull()?.price ?: 0.0 return ChartHeaderUIModel.build( @@ -125,7 +124,7 @@ fun portfolioChartHeader( priceChangePercentage = target.priceChangePercentage, type = ChartValueType.PriceChange, timestamp = selectedPoint?.timestamp, - headerValue = if (showHeaderValue) target.price else null, + headerValue = if (uiModel.showHeaderValue) target.price else null, priceFormatter = uiModel.priceFormatter, priceChangeFormatter = uiModel.priceChangeFormatter, dateFormatter = ::getRelativeDate, diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index 865e27a987..24b4b8397a 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -57,25 +57,12 @@ class PortfolioChartViewModel internal constructor( val selectedChartType = _selectedChartType.asStateFlow() private val selectedPeriod = MutableStateFlow(ChartPeriod.All) - private val viewState = MutableStateFlow(ChartViewState.Loading) private val refreshTrigger = MutableStateFlow(0L) private val refreshState = MutableStateFlow(false) val showSegmentedControl = observePerpetualWallet() .map { it != null } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) - - val showChartTypePicker = selectedType - .map { it == PortfolioType.Perpetuals } - .stateIn(viewModelScope, SharingStarted.Eagerly, initialType == PortfolioType.Perpetuals) - - val showHeaderValue = combine(selectedType, selectedChartType) { type, chartType -> - type == PortfolioType.Wallet || chartType == PortfolioChartType.Value - }.stateIn(viewModelScope, SharingStarted.Eagerly, initialType == PortfolioType.Wallet) - - val chartUIState = combine(selectedPeriod, viewState) { period, viewState -> - ChartUIModel.State(period = period, viewState = viewState) - }.stateIn(viewModelScope, SharingStarted.Eagerly, ChartUIModel.State()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), false) val isRefreshing = refreshState.asStateFlow() @@ -86,71 +73,67 @@ class PortfolioChartViewModel internal constructor( refreshTrigger, ) { type, period, currency, _ -> Triple(type, period, currency) } .mapLatest { (type, period, currency) -> - try { - val data = getPortfolioData.getPortfolioData(type, period, currency) - val periods = data.availablePeriods - if (periods.isNotEmpty() && !periods.contains(period)) { - selectedPeriod.value = periods.first() - null - } else { - viewState.value = data.chartState(_selectedChartType.value) - data to type.displayCurrency(currency) - } + val data = try { + getPortfolioData.getPortfolioData(type, period, currency) } catch (e: Exception) { currentCoroutineContext().ensureActive() - viewState.value = ChartViewState.Error null - } finally { - refreshState.value = false + } + refreshState.value = false + val periods = data?.availablePeriods.orEmpty() + if (data != null && periods.isNotEmpty() && !periods.contains(period)) { + selectedPeriod.compareAndSet(period, periods.first()) + null + } else { + PortfolioFetch(type, period, data, type.displayCurrency(currency)) } } .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), null) - val chartUIModel = combine(selectedPeriod, selectedChartType, portfolio) { period, chartType, data -> - val (portfolioData, currency) = data ?: return@combine ChartUIModel(period = period) - ChartUIModel.from(portfolioData.chartValues(chartType), period, currency) + val chartUIState = combine(selectedType, selectedPeriod, selectedChartType, portfolio) { type, period, chartType, fetch -> + ChartUIModel.State(period = period, viewState = fetch.viewState(type, period, chartType)) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel.State()) + + val chartUIModel = combine(selectedType, selectedPeriod, selectedChartType, portfolio) { type, period, chartType, fetch -> + if (fetch?.matches(type, period) != true || fetch.data == null) { + ChartUIModel(period = period) + } else { + ChartUIModel.from( + values = fetch.data.chartValues(chartType), + period = period, + currency = fetch.currency, + showHeaderValue = type == PortfolioType.Wallet || chartType == PortfolioChartType.Value, + ) + } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel()) val statistics = portfolio - .map { it?.first?.statistics.orEmpty() } + .map { it?.data?.statistics.orEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), emptyList()) val currency = portfolio - .map { it?.second ?: Currency.USD } + .map { it?.currency ?: Currency.USD } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), Currency.USD) val availablePeriods = portfolio - .map { it?.first?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: defaultPeriods } + .map { it?.data?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: defaultPeriods } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), defaultPeriods) fun setType(type: PortfolioType) { - if (type == _selectedType.value) { - return - } _selectedType.value = type - viewState.value = ChartViewState.Loading } fun setChartType(chartType: PortfolioChartType) { - if (chartType == _selectedChartType.value) { - return - } _selectedChartType.value = chartType - portfolio.value?.first?.let { viewState.value = it.chartState(chartType) } } fun setPeriod(period: ChartPeriod) { - if (period == selectedPeriod.value) { - return - } selectedPeriod.value = period - viewState.value = ChartViewState.Loading } fun refresh() { refreshState.value = true - viewState.value = ChartViewState.Loading refreshTrigger.value = refreshTrigger.value + 1 } @@ -168,13 +151,34 @@ class PortfolioChartViewModel internal constructor( ) } +private class PortfolioFetch( + val type: PortfolioType, + val period: ChartPeriod, + val data: PortfolioData?, + val currency: Currency, +) { + fun matches(type: PortfolioType, period: ChartPeriod): Boolean = + this.type == type && this.period == period +} + +private fun PortfolioFetch?.viewState( + type: PortfolioType, + period: ChartPeriod, + chartType: PortfolioChartType, +): ChartViewState = when { + this?.matches(type, period) != true -> ChartViewState.Loading + data == null -> ChartViewState.Error + else -> data.chartState(chartType) +} + private fun PortfolioData.chartValues(chartType: PortfolioChartType): List = (charts.firstOrNull { it.chartType == chartType } ?: charts.firstOrNull())?.values.orEmpty() private fun PortfolioData.chartState(chartType: PortfolioChartType): ChartViewState { - val values = chartValues(chartType).map { it.value } - val hasVariation = (values.minOrNull() ?: 0.0) != (values.maxOrNull() ?: 0.0) - return if (values.size < MinChartPoints || !hasVariation) ChartViewState.Empty else ChartViewState.Ready + val values = chartValues(chartType) + val hasVariation = values.size >= MinChartPoints && + values.minOf { it.value } != values.maxOf { it.value } + return if (hasVariation) ChartViewState.Ready else ChartViewState.Empty } private fun PortfolioType.displayCurrency(fetchCurrency: Currency): Currency = diff --git a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt index 3c3acd26c6..121c65fc6f 100644 --- a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt +++ b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt @@ -76,7 +76,7 @@ class PortfolioChartViewModelTest { val uiModel = viewModel.chartUIModel.first { it.chartPoints.size == 3 } assertEquals(3, uiModel.chartPoints.size) - assertEquals(ChartViewState.Ready, viewModel.chartUIState.value.viewState) + assertEquals(ChartViewState.Ready, viewModel.chartUIState.first { it.viewState != ChartViewState.Loading }.viewState) } @Test @@ -86,7 +86,7 @@ class PortfolioChartViewModelTest { val viewModel = createViewModel() viewModel.chartUIModel.first { it.chartPoints.size == 2 } - assertEquals(ChartPeriod.All, viewModel.chartUIState.value.period) + assertEquals(ChartPeriod.All, viewModel.chartUIState.first { it.viewState != ChartViewState.Loading }.period) coVerify(exactly = 1) { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } } From 9289a8008033f6c6f954edbaa42f8fa22a2ae1d8 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:25:04 +0300 Subject: [PATCH 14/17] Reuse shared property rows and formatters for perpetual statistics Review findings: the PnL rows hand-built ListItem/PropertyTitleText/ PropertyDataText although PropertyItem(title, data, dataColor) renders the same row and is how TransactionDetailsScene shows PnL today; leverage was formatted with a locale-fragile String.format while gemcore owns leverage display (formatLeverage), which gains a locale-aware Double overload with iOS's two fraction digits; and the all-time/perpetual split was an either/or branch that would silently drop non-all-time statistics from a mixed list - the statistics now partition so both kinds render. --- .../presents/chart/PortfolioStatistics.kt | 54 +++++++------------ .../domains/perpetual/PerpetualLeverage.kt | 11 ++++ 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt index 0b328b4a13..5488eeb320 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt @@ -1,52 +1,50 @@ package com.gemwallet.android.features.asset.presents.chart -import androidx.annotation.StringRes import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource import com.gemwallet.android.domains.percentage.PercentageFormatterStyle import com.gemwallet.android.domains.percentage.formatAsPercentage +import com.gemwallet.android.domains.perpetual.formatLeverage import com.gemwallet.android.domains.price.toValueDirection import com.gemwallet.android.features.asset.viewmodels.chart.models.AllTimeUIModel import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.model.PriceChangeFormatter import com.gemwallet.android.ui.R -import com.gemwallet.android.ui.components.list_item.ListItem import com.gemwallet.android.ui.components.list_item.SubheaderItem import com.gemwallet.android.ui.components.list_item.color -import com.gemwallet.android.ui.components.list_item.property.PropertyDataText import com.gemwallet.android.ui.components.list_item.property.PropertyItem -import com.gemwallet.android.ui.components.list_item.property.PropertyTitleText import com.gemwallet.android.ui.components.list_item.property.itemsPositioned -import com.gemwallet.android.ui.models.ListPosition import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PortfolioMarginUsage import com.wallet.core.primitives.PortfolioStatistic -private const val LeverageFractionDigits = 2 - internal fun LazyListScope.portfolioStatistics(currency: Currency, statistics: List) { if (statistics.isEmpty()) return item { SubheaderItem(R.string.common_info) } - val allTimeItems = statistics.mapNotNull(PortfolioStatistic::asAllTimeUIModel) - if (allTimeItems.isNotEmpty()) { - allTimeProperties(currency, allTimeItems) - } else { - perpetualStatistics(currency, statistics) - } + val (allTime, perpetual) = statistics.partition { it.asAllTimeUIModel() != null } + allTimeProperties(currency, allTime.mapNotNull(PortfolioStatistic::asAllTimeUIModel)) + perpetualStatistics(currency, perpetual) } private fun LazyListScope.perpetualStatistics(currency: Currency, statistics: List) { + if (statistics.isEmpty()) return val currencyFormatter = CurrencyFormatter(currency = currency) val priceChangeFormatter = PriceChangeFormatter(currencyFormatter) itemsPositioned(statistics) { position, statistic -> when (statistic) { - is PortfolioStatistic.UnrealizedPnl -> - PnlStatRow(R.string.perpetual_unrealized_pnl, statistic.content, priceChangeFormatter, position) - is PortfolioStatistic.AllTimePnl -> - PnlStatRow(R.string.perpetual_all_time_pnl, statistic.content, priceChangeFormatter, position) + is PortfolioStatistic.UnrealizedPnl -> PropertyItem( + title = R.string.perpetual_unrealized_pnl, + data = priceChangeFormatter.string(statistic.content), + dataColor = statistic.content.toValueDirection().color(), + listPosition = position, + ) + is PortfolioStatistic.AllTimePnl -> PropertyItem( + title = R.string.perpetual_all_time_pnl, + data = priceChangeFormatter.string(statistic.content), + dataColor = statistic.content.toValueDirection().color(), + listPosition = position, + ) is PortfolioStatistic.AccountLeverage -> - PropertyItem(R.string.perpetual_account_leverage, statistic.content.leverageText(), listPosition = position) + PropertyItem(R.string.perpetual_account_leverage, statistic.content.formatLeverage(), listPosition = position) is PortfolioStatistic.MarginUsage -> PropertyItem(R.string.perpetual_margin_usage, statistic.content.marginText(currencyFormatter), listPosition = position) is PortfolioStatistic.Volume -> @@ -56,27 +54,11 @@ private fun LazyListScope.perpetualStatistics(currency: Currency, statistics: Li } } -@Composable -private fun PnlStatRow( - @StringRes title: Int, - value: Double, - priceChangeFormatter: PriceChangeFormatter, - listPosition: ListPosition, -) { - ListItem( - listPosition = listPosition, - title = { PropertyTitleText(text = stringResource(title)) }, - trailing = { PropertyDataText(priceChangeFormatter.string(value), color = value.toValueDirection().color()) }, - ) -} - private fun PortfolioStatistic.asAllTimeUIModel(): AllTimeUIModel? = when (this) { is PortfolioStatistic.AllTimeHigh -> AllTimeUIModel.High(content.date, content.value.toDouble(), content.percentage.toDouble()) is PortfolioStatistic.AllTimeLow -> AllTimeUIModel.Low(content.date, content.value.toDouble(), content.percentage.toDouble()) else -> null } -private fun Double.leverageText(): String = "%.${LeverageFractionDigits}fx".format(this) - private fun PortfolioMarginUsage.marginText(formatter: CurrencyFormatter): String = "${formatter.string(accountValue * usage)} (${(usage * 100).formatAsPercentage(PercentageFormatterStyle.PercentSignLess)})" diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualLeverage.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualLeverage.kt index f1c0d498fa..3e3a6276c9 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualLeverage.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/domains/perpetual/PerpetualLeverage.kt @@ -1,3 +1,14 @@ package com.gemwallet.android.domains.perpetual +import java.text.NumberFormat + +private const val LeverageFractionDigits = 2 + fun Int.formatLeverage(): String = "${this}x" + +fun Double.formatLeverage(): String = NumberFormat.getNumberInstance() + .apply { + minimumFractionDigits = LeverageFractionDigits + maximumFractionDigits = LeverageFractionDigits + } + .format(this) + "x" From a3f76a6fbb31a08840b5de26f6838aa817b17e79 Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:30:42 +0300 Subject: [PATCH 15/17] Align the portfolio data layer with repo conventions Review findings: the wallet fetch read the currency rate only after the portfolio POST and mapped a missing rate to an empty result, wasting the round-trip and diverging from iOS, which reads the rate first and fails into the error state - the rate is now checked before the request and throws; seconds-to-millis conversions used TimeUnit and a magic multiply instead of the shared secondsToMillis extension; the wallet period list existed twice (coordinator constant + ViewModel fallback) and is now one constant beside the GetPortfolioData interface; the portfolio sub-mappers follow the file's private-mapper precedent; new imports are sorted into their blocks. --- .../android/ui/navigation/RootRoute.kt | 2 +- .../android/ui/navigation/WalletNavGraph.kt | 2 +- .../blockchain/gemstone/GemPerpetualMapper.kt | 9 +++++---- .../asset/GetPortfolioDataImpl.kt | 19 ++++++------------- .../data/coordinators/di/AssetModule.kt | 6 +++--- .../asset/GetPortfolioDataImplTest.kt | 13 +++---------- .../viewmodels/PortfolioChartViewModel.kt | 13 +++---------- .../assets/coordinators/GetPortfolioData.kt | 8 ++++++++ 8 files changed, 30 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt index 602f5b65d9..68ea52b455 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/RootRoute.kt @@ -68,8 +68,8 @@ import com.gemwallet.android.ui.navigation.routes.SupportRoute import com.gemwallet.android.ui.navigation.routes.SwapPairRoute import com.gemwallet.android.ui.navigation.routes.SwapRoute import com.gemwallet.android.ui.navigation.routes.SwapSelectRoute -import com.gemwallet.android.ui.navigation.routes.TransactionDetailsRoute import com.gemwallet.android.ui.navigation.routes.PortfolioChartRoute +import com.gemwallet.android.ui.navigation.routes.TransactionDetailsRoute import com.gemwallet.android.ui.navigation.routes.WalletDetailsRoute import com.gemwallet.android.ui.navigation.routes.WalletImageRoute import com.gemwallet.android.ui.navigation.routes.WalletPhraseRoute diff --git a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt index b3a7ca3deb..acfca01dc4 100644 --- a/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt +++ b/android/app/src/main/kotlin/com/gemwallet/android/ui/navigation/WalletNavGraph.kt @@ -37,12 +37,12 @@ import com.gemwallet.android.ui.navigation.routes.addAssetScreen import com.gemwallet.android.ui.navigation.routes.amount import com.gemwallet.android.ui.navigation.routes.assetChartScreen import com.gemwallet.android.ui.navigation.routes.assetScreen -import com.gemwallet.android.ui.navigation.routes.portfolioChartScreen import com.gemwallet.android.ui.navigation.routes.bridgesScreen import com.gemwallet.android.ui.navigation.routes.confirm import com.gemwallet.android.ui.navigation.routes.fiatScreen import com.gemwallet.android.ui.navigation.routes.nftCollection import com.gemwallet.android.ui.navigation.routes.perpetualScreen +import com.gemwallet.android.ui.navigation.routes.portfolioChartScreen import com.gemwallet.android.ui.navigation.routes.receiveScreen import com.gemwallet.android.ui.navigation.routes.recipientInput import com.gemwallet.android.ui.navigation.routes.referral diff --git a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt index 982f4724c9..0031d02157 100644 --- a/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt +++ b/android/blockchain/src/main/kotlin/com/gemwallet/android/blockchain/gemstone/GemPerpetualMapper.kt @@ -1,6 +1,7 @@ package com.gemwallet.android.blockchain.gemstone import com.gemwallet.android.domains.asset.toDTO +import com.gemwallet.android.ext.secondsToMillis import com.gemwallet.android.ext.toAssetId import com.gemwallet.android.ext.toPerpetualId import com.wallet.core.primitives.ChartCandleStick @@ -134,14 +135,14 @@ fun GemChartCandleStick.toDTO(): ChartCandleStick { ) } -fun GemChartDateValue.toDTO(): ChartDateValue { +private fun GemChartDateValue.toDTO(): ChartDateValue { return ChartDateValue( - date = date * 1_000L, + date = date.secondsToMillis(), value = value, ) } -fun GemPerpetualAccountSummary.toDTO(): PerpetualAccountSummary { +private fun GemPerpetualAccountSummary.toDTO(): PerpetualAccountSummary { return PerpetualAccountSummary( accountValue = accountValue, accountLeverage = accountLeverage, @@ -150,7 +151,7 @@ fun GemPerpetualAccountSummary.toDTO(): PerpetualAccountSummary { ) } -fun GemPerpetualPortfolioTimeframeData.toDTO(): PerpetualPortfolioTimeframeData { +private fun GemPerpetualPortfolioTimeframeData.toDTO(): PerpetualPortfolioTimeframeData { return PerpetualPortfolioTimeframeData( accountValueHistory = accountValueHistory.map { it.toDTO() }, pnlHistory = pnlHistory.map { it.toDTO() }, diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt index c9f49875d1..e34375376a 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImpl.kt @@ -1,11 +1,13 @@ package com.gemwallet.android.data.coordinators.asset import com.gemwallet.android.application.assets.coordinators.GetPortfolioData +import com.gemwallet.android.application.assets.coordinators.walletChartPeriods import com.gemwallet.android.blockchain.services.PerpetualService import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.session.SessionRepository import com.gemwallet.android.data.services.gemapi.GemDeviceApiClient import com.gemwallet.android.ext.hyperliquidAccount +import com.gemwallet.android.ext.secondsToMillis import com.gemwallet.android.model.getTotalAmount import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod @@ -22,15 +24,6 @@ import com.wallet.core.primitives.PortfolioStatistic import com.wallet.core.primitives.PortfolioType import kotlinx.coroutines.flow.firstOrNull import java.math.BigInteger -import java.util.concurrent.TimeUnit - -private val walletChartPeriods = listOf( - ChartPeriod.Day, - ChartPeriod.Week, - ChartPeriod.Month, - ChartPeriod.Year, - ChartPeriod.All, -) class GetPortfolioDataImpl( private val gemDeviceApiClient: GemDeviceApiClient, @@ -49,6 +42,9 @@ class GetPortfolioDataImpl( } private suspend fun getWalletData(period: ChartPeriod, currency: Currency): PortfolioData { + val rate = checkNotNull(assetsRepository.getCurrencyRate(currency).firstOrNull()?.rate) { + "Missing currency rate for ${currency.string}" + } val assets = assetsRepository.getAssetsInfo().firstOrNull().orEmpty() .mapNotNull { assetInfo -> val total = assetInfo.balance.balance.getTotalAmount() @@ -59,12 +55,9 @@ class GetPortfolioDataImpl( period = period.string, request = PortfolioAssetsRequest(assets = assets), ) - val rate = assetsRepository.getCurrencyRate(currency).firstOrNull()?.rate - ?: return PortfolioData(charts = emptyList(), statistics = emptyList(), availablePeriods = walletChartPeriods) - val values = portfolio.values .sortedBy { it.timestamp } - .map { ChartDateValue(date = TimeUnit.SECONDS.toMillis(it.timestamp.toLong()), value = it.value * rate) } + .map { ChartDateValue(date = it.timestamp.toLong().secondsToMillis(), value = it.value * rate) } val statistics = listOfNotNull( portfolio.allTimeHigh?.let { PortfolioStatistic.AllTimeHigh(it) }, portfolio.allTimeLow?.let { PortfolioStatistic.AllTimeLow(it) }, diff --git a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt index 65afca79e9..419cbc07ee 100644 --- a/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt +++ b/android/data/coordinators/src/main/kotlin/com/gemwallet/android/data/coordinators/di/AssetModule.kt @@ -14,8 +14,8 @@ import com.gemwallet.android.application.assets.coordinators.GetChartPeriod import com.gemwallet.android.application.assets.coordinators.GetHideBalancesState import com.gemwallet.android.application.assets.coordinators.GetImportInProgress import com.gemwallet.android.application.assets.coordinators.GetSearchLists -import com.gemwallet.android.application.assets.coordinators.GetShowWelcomeBanner import com.gemwallet.android.application.assets.coordinators.GetPortfolioData +import com.gemwallet.android.application.assets.coordinators.GetShowWelcomeBanner import com.gemwallet.android.application.assets.coordinators.GetWalletSummary import com.gemwallet.android.application.assets.coordinators.EnsureWalletAssets import com.gemwallet.android.application.assets.coordinators.HideAsset @@ -28,6 +28,7 @@ import com.gemwallet.android.application.assets.coordinators.SyncAssets import com.gemwallet.android.application.assets.coordinators.ToggleAssetPin import com.gemwallet.android.application.assets.coordinators.ToggleHideBalances import com.gemwallet.android.application.wallet_import.coordinators.GetImportWalletState +import com.gemwallet.android.blockchain.services.PerpetualService import com.gemwallet.android.cases.banners.HasMultiSign import com.gemwallet.android.cases.tokens.SyncAssetPrices import com.gemwallet.android.data.coordinators.asset.EnableAssetImpl @@ -43,8 +44,8 @@ import com.gemwallet.android.data.coordinators.asset.GetChartPeriodImpl import com.gemwallet.android.data.coordinators.asset.GetHideBalancesStateImpl import com.gemwallet.android.data.coordinators.asset.GetImportInProgressImpl import com.gemwallet.android.data.coordinators.asset.GetSearchListsImpl -import com.gemwallet.android.data.coordinators.asset.GetShowWelcomeBannerImpl import com.gemwallet.android.data.coordinators.asset.GetPortfolioDataImpl +import com.gemwallet.android.data.coordinators.asset.GetShowWelcomeBannerImpl import com.gemwallet.android.data.coordinators.asset.GetWalletSummaryImpl import com.gemwallet.android.data.coordinators.asset.DeviceAssetsSyncService import com.gemwallet.android.data.coordinators.asset.EnsureWalletAssetsImpl @@ -57,7 +58,6 @@ import com.gemwallet.android.data.coordinators.asset.SyncAssetInfoImpl import com.gemwallet.android.data.coordinators.asset.SyncAssetsImpl import com.gemwallet.android.data.coordinators.asset.ToggleAssetPinImpl import com.gemwallet.android.data.coordinators.asset.ToggleHideBalancesImpl -import com.gemwallet.android.blockchain.services.PerpetualService import com.gemwallet.android.data.repositories.assets.AssetsRepository import com.gemwallet.android.data.repositories.config.UserConfig import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet diff --git a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt index d69a6c46d4..3858d7024d 100644 --- a/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt +++ b/android/data/coordinators/src/test/kotlin/com/gemwallet/android/data/coordinators/asset/GetPortfolioDataImplTest.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class GetPortfolioDataImplTest { @@ -98,16 +97,10 @@ class GetPortfolioDataImplTest { subject.getPortfolioData(PortfolioType.Perpetuals, period = ChartPeriod.All, currency = Currency.USD) } - @Test - fun getPortfolioData_returnsEmptyChartsWhenRateMissing() = runTest { - val bitcoin = mockAsset() - every { assetsRepository.getAssetsInfo() } returns - flowOf(listOf(mockAssetInfo(asset = bitcoin, balance = AssetBalance.create(bitcoin, available = "1000")))) + @Test(expected = IllegalStateException::class) + fun getPortfolioData_throwsWhenRateMissing() = runTest { every { assetsRepository.getCurrencyRate(Currency.EUR) } returns flowOf(null) - coEvery { gemDeviceApiClient.getPortfolioAssets(any(), any()) } returns portfolio() - - val result = subject.getPortfolioData(PortfolioType.Wallet, period = ChartPeriod.Day, currency = Currency.EUR) - assertTrue(result.charts.isEmpty()) + subject.getPortfolioData(PortfolioType.Wallet, period = ChartPeriod.Day, currency = Currency.EUR) } } diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index 24b4b8397a..25c4529f54 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.assets.coordinators.GetPortfolioData +import com.gemwallet.android.application.assets.coordinators.walletChartPeriods import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel @@ -34,14 +35,6 @@ import javax.inject.Inject private const val MinChartPoints = 2 private const val StopTimeoutMillis = 5_000L -private val defaultPeriods = listOf( - ChartPeriod.Day, - ChartPeriod.Week, - ChartPeriod.Month, - ChartPeriod.Year, - ChartPeriod.All, -) - @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class PortfolioChartViewModel internal constructor( @@ -117,8 +110,8 @@ class PortfolioChartViewModel internal constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), Currency.USD) val availablePeriods = portfolio - .map { it?.data?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: defaultPeriods } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), defaultPeriods) + .map { it?.data?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: walletChartPeriods } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), walletChartPeriods) fun setType(type: PortfolioType) { _selectedType.value = type diff --git a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt index eae1d854f0..56ba7f7906 100644 --- a/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt +++ b/android/gemcore/src/main/kotlin/com/gemwallet/android/application/assets/coordinators/GetPortfolioData.kt @@ -5,6 +5,14 @@ import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PortfolioData import com.wallet.core.primitives.PortfolioType +val walletChartPeriods = listOf( + ChartPeriod.Day, + ChartPeriod.Week, + ChartPeriod.Month, + ChartPeriod.Year, + ChartPeriod.All, +) + interface GetPortfolioData { suspend fun getPortfolioData( type: PortfolioType, From 948ff9a80530451a96bdff93c212cfed2323e76b Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:46:45 +0300 Subject: [PATCH 16/17] Share the tagged chart fetch and PnL row across charts The tagged-fetch pattern from the portfolio ViewModel moves to ui-models as ChartFetch(request, data) with a shared viewState derivation - the Android counterpart of iOS StateViewType, where the fetch result and its identity are one value. The asset ChartViewModel adopts it, deleting its imperative viewState writes, which had the same cancelled-refresh and unordered Ready-vs-data races as the portfolio chart. The candlestick chart keeps its streaming flow: flatMapLatest already binds candles to their period, so there is no identity to tag. The chart type picker flag returns as a named value in the scene, and the duplicated PnL PropertyItem calls collapse into a shared PnlPropertyItem next to the other property rows. --- .../presents/chart/PortfolioChartScene.kt | 3 +- .../presents/chart/PortfolioStatistics.kt | 19 ++----- .../chart/viewmodels/ChartViewModel.kt | 38 +++++++------- .../viewmodels/PortfolioChartViewModel.kt | 50 ++++++++----------- .../android/ui/models/chart/ChartFetch.kt | 19 +++++++ .../list_item/property/PnlPropertyItem.kt | 23 +++++++++ 6 files changed, 89 insertions(+), 63 deletions(-) create mode 100644 android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt create mode 100644 android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PnlPropertyItem.kt diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index 3a75374c93..9a6a55c4aa 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -46,6 +46,7 @@ fun PortfolioChartScene( val showSegmentedControl by viewModel.showSegmentedControl.collectAsStateWithLifecycle() val selectedChartType by viewModel.selectedChartType.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() + val showChartTypePicker = selectedType == PortfolioType.Perpetuals val pullToRefreshState = rememberPullToRefreshState() Scene( @@ -58,7 +59,7 @@ fun PortfolioChartScene( }, onClose = onCancel, actions = { - if (selectedType == PortfolioType.Perpetuals) { + if (showChartTypePicker) { ChartTypeSelector(selected = selectedChartType, onSelect = viewModel::setChartType) } }, diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt index 5488eeb320..5427683b76 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioStatistics.kt @@ -4,13 +4,12 @@ import androidx.compose.foundation.lazy.LazyListScope import com.gemwallet.android.domains.percentage.PercentageFormatterStyle import com.gemwallet.android.domains.percentage.formatAsPercentage import com.gemwallet.android.domains.perpetual.formatLeverage -import com.gemwallet.android.domains.price.toValueDirection import com.gemwallet.android.features.asset.viewmodels.chart.models.AllTimeUIModel import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.model.PriceChangeFormatter import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.list_item.SubheaderItem -import com.gemwallet.android.ui.components.list_item.color +import com.gemwallet.android.ui.components.list_item.property.PnlPropertyItem import com.gemwallet.android.ui.components.list_item.property.PropertyItem import com.gemwallet.android.ui.components.list_item.property.itemsPositioned import com.wallet.core.primitives.Currency @@ -31,18 +30,10 @@ private fun LazyListScope.perpetualStatistics(currency: Currency, statistics: Li val priceChangeFormatter = PriceChangeFormatter(currencyFormatter) itemsPositioned(statistics) { position, statistic -> when (statistic) { - is PortfolioStatistic.UnrealizedPnl -> PropertyItem( - title = R.string.perpetual_unrealized_pnl, - data = priceChangeFormatter.string(statistic.content), - dataColor = statistic.content.toValueDirection().color(), - listPosition = position, - ) - is PortfolioStatistic.AllTimePnl -> PropertyItem( - title = R.string.perpetual_all_time_pnl, - data = priceChangeFormatter.string(statistic.content), - dataColor = statistic.content.toValueDirection().color(), - listPosition = position, - ) + is PortfolioStatistic.UnrealizedPnl -> + PnlPropertyItem(R.string.perpetual_unrealized_pnl, statistic.content, priceChangeFormatter, position) + is PortfolioStatistic.AllTimePnl -> + PnlPropertyItem(R.string.perpetual_all_time_pnl, statistic.content, priceChangeFormatter, position) is PortfolioStatistic.AccountLeverage -> PropertyItem(R.string.perpetual_account_leverage, statistic.content.formatLeverage(), listPosition = position) is PortfolioStatistic.MarginUsage -> diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt index 7d577f0ad4..e25da70c5e 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt @@ -10,7 +10,10 @@ import com.gemwallet.android.application.assets.coordinators.SetChartPeriod import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel import com.gemwallet.android.features.asset.viewmodels.chart.models.from +import com.gemwallet.android.ui.models.chart.ChartFetch import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.chart.MinChartPoints +import com.gemwallet.android.ui.models.chart.viewState import com.gemwallet.android.ui.models.navigation.requireAssetId import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.ChartPeriod @@ -47,14 +50,9 @@ class ChartViewModel internal constructor( .map { it?.price } .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val selectedPeriod = MutableStateFlow(getChartPeriod()) - private val viewState = MutableStateFlow(ChartViewState.Loading) private val refreshTrigger = MutableStateFlow(0L) private val refreshState = MutableStateFlow(false) - val chartUIState = combine(selectedPeriod, viewState) { period, viewState -> - ChartUIModel.State(period = period, viewState = viewState) - }.stateIn(viewModelScope, SharingStarted.Eagerly, ChartUIModel.State()) - val isRefreshing = refreshState.asStateFlow() private val chartPrices = combine( @@ -63,28 +61,34 @@ class ChartViewModel internal constructor( refreshTrigger, ) { period, currency, _ -> period to currency } .mapLatest { (period, currency) -> - try { - val prices = request(period, currency) - viewState.value = if (prices.size < 2) ChartViewState.Empty else ChartViewState.Ready - prices + val prices = try { + request(period, currency) } catch (e: Exception) { currentCoroutineContext().ensureActive() - viewState.value = ChartViewState.Error - emptyList() - } finally { - refreshState.value = false + null } + refreshState.value = false + ChartFetch(request = period, data = prices) } .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val chartUIState = combine(selectedPeriod, chartPrices) { period, fetch -> + ChartUIModel.State( + period = period, + viewState = fetch.viewState(period) { prices -> + if (prices.size < MinChartPoints) ChartViewState.Empty else ChartViewState.Ready + }, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, ChartUIModel.State()) val chartUIModel = combine( assetPriceInfo, selectedPeriod, chartPrices, getCurrentCurrency.getCurrency().distinctUntilChanged(), - ) { priceInfo, period, prices, currency -> - ChartUIModel.from(prices, priceInfo, period, currency) + ) { priceInfo, period, fetch, currency -> + ChartUIModel.from(fetch?.takeIf { it.matches(period) }?.data.orEmpty(), priceInfo, period, currency) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChartUIModel()) private suspend fun request(period: ChartPeriod, currency: Currency): List { @@ -97,12 +101,10 @@ class ChartViewModel internal constructor( } setChartPeriod(period) selectedPeriod.value = period - viewState.value = ChartViewState.Loading } fun refresh() { refreshState.value = true - viewState.value = ChartViewState.Loading refreshTrigger.value = refreshTrigger.value + 1 } diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index 25c4529f54..aebd22a738 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -9,7 +9,10 @@ import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel import com.gemwallet.android.features.asset.viewmodels.chart.models.from +import com.gemwallet.android.ui.models.chart.ChartFetch import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.chart.MinChartPoints +import com.gemwallet.android.ui.models.chart.viewState import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.Currency @@ -32,7 +35,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import javax.inject.Inject -private const val MinChartPoints = 2 private const val StopTimeoutMillis = 5_000L @OptIn(ExperimentalCoroutinesApi::class) @@ -78,39 +80,43 @@ class PortfolioChartViewModel internal constructor( selectedPeriod.compareAndSet(period, periods.first()) null } else { - PortfolioFetch(type, period, data, type.displayCurrency(currency)) + ChartFetch( + request = PortfolioRequest(type, period), + data = data?.let { PortfolioResult(it, type.displayCurrency(currency)) }, + ) } } .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), null) val chartUIState = combine(selectedType, selectedPeriod, selectedChartType, portfolio) { type, period, chartType, fetch -> - ChartUIModel.State(period = period, viewState = fetch.viewState(type, period, chartType)) + ChartUIModel.State( + period = period, + viewState = fetch.viewState(PortfolioRequest(type, period)) { it.portfolio.chartState(chartType) }, + ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel.State()) val chartUIModel = combine(selectedType, selectedPeriod, selectedChartType, portfolio) { type, period, chartType, fetch -> - if (fetch?.matches(type, period) != true || fetch.data == null) { - ChartUIModel(period = period) - } else { + fetch?.takeIf { it.matches(PortfolioRequest(type, period)) }?.data?.let { result -> ChartUIModel.from( - values = fetch.data.chartValues(chartType), + values = result.portfolio.chartValues(chartType), period = period, - currency = fetch.currency, + currency = result.currency, showHeaderValue = type == PortfolioType.Wallet || chartType == PortfolioChartType.Value, ) - } + } ?: ChartUIModel(period = period) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel()) val statistics = portfolio - .map { it?.data?.statistics.orEmpty() } + .map { it?.data?.portfolio?.statistics.orEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), emptyList()) val currency = portfolio - .map { it?.currency ?: Currency.USD } + .map { it?.data?.currency ?: Currency.USD } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), Currency.USD) val availablePeriods = portfolio - .map { it?.data?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: walletChartPeriods } + .map { it?.data?.portfolio?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: walletChartPeriods } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), walletChartPeriods) fun setType(type: PortfolioType) { @@ -144,25 +150,9 @@ class PortfolioChartViewModel internal constructor( ) } -private class PortfolioFetch( - val type: PortfolioType, - val period: ChartPeriod, - val data: PortfolioData?, - val currency: Currency, -) { - fun matches(type: PortfolioType, period: ChartPeriod): Boolean = - this.type == type && this.period == period -} +private data class PortfolioRequest(val type: PortfolioType, val period: ChartPeriod) -private fun PortfolioFetch?.viewState( - type: PortfolioType, - period: ChartPeriod, - chartType: PortfolioChartType, -): ChartViewState = when { - this?.matches(type, period) != true -> ChartViewState.Loading - data == null -> ChartViewState.Error - else -> data.chartState(chartType) -} +private class PortfolioResult(val portfolio: PortfolioData, val currency: Currency) private fun PortfolioData.chartValues(chartType: PortfolioChartType): List = (charts.firstOrNull { it.chartType == chartType } ?: charts.firstOrNull())?.values.orEmpty() diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt new file mode 100644 index 0000000000..f776603de4 --- /dev/null +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt @@ -0,0 +1,19 @@ +package com.gemwallet.android.ui.models.chart + +const val MinChartPoints = 2 + +class ChartFetch( + val request: Request, + val data: Data?, +) { + fun matches(request: Request): Boolean = this.request == request +} + +fun ChartFetch?.viewState( + request: Request, + dataState: (Data) -> ChartViewState, +): ChartViewState = when { + this?.matches(request) != true -> ChartViewState.Loading + data == null -> ChartViewState.Error + else -> dataState(data) +} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PnlPropertyItem.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PnlPropertyItem.kt new file mode 100644 index 0000000000..a3b0b14a7d --- /dev/null +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/list_item/property/PnlPropertyItem.kt @@ -0,0 +1,23 @@ +package com.gemwallet.android.ui.components.list_item.property + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import com.gemwallet.android.domains.price.toValueDirection +import com.gemwallet.android.model.PriceChangeFormatter +import com.gemwallet.android.ui.components.list_item.color +import com.gemwallet.android.ui.models.ListPosition + +@Composable +fun PnlPropertyItem( + @StringRes title: Int, + value: Double, + formatter: PriceChangeFormatter, + listPosition: ListPosition, +) { + PropertyItem( + title = title, + data = formatter.string(value), + dataColor = value.toValueDirection().color(), + listPosition = listPosition, + ) +} From 4534e253bca42fa45e6dac4ccdadba11e5d515be Mon Sep 17 00:00:00 2001 From: gemdev111 <171273137+gemdev111@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:17:34 +0300 Subject: [PATCH 17/17] Carry chart data inside StateViewType across the chart flows Port iOS StateViewType (noData/loading/data/error) to ui-models and make it the single chart state primitive: ChartViewState and the interim ChartFetch are gone, and the state now carries its payload the way iOS StateViewType does. Each chart fetch is a transformLatest that emits its request state first and the result after, so a stale result can never render under a newer selection and no imperative state writes remain. The request snapshot is a feature model like iOS Types/PortfolioState: PortfolioState(type, period, currency, data) with displayCurrency computed on it, and AssetChartState for the asset chart - the currency rides in the request, not beside the result. The portfolio and asset chart ViewModels expose one ChartUIModel.State(period, chart); the candlestick flow emits its states in-band on the stream that merges websocket candles; ChartStateView is generic over the payload. The chart ViewModels share the named StopTimeoutMillis instead of a raw 5_000. --- .../features/asset/presents/chart/Chart.kt | 33 +++---- .../presents/chart/PortfolioChartScene.kt | 8 +- .../chart/models/AssetChartState.kt | 12 +++ .../viewmodels/chart/models/ChartUIModel.kt | 7 +- .../viewmodels/chart/models/PortfolioState.kt | 22 +++++ .../chart/viewmodels/ChartViewModel.kt | 49 +++++----- .../viewmodels/PortfolioChartViewModel.kt | 98 +++++++++---------- .../chart/viewmodels/ChartViewModelTest.kt | 19 ++-- .../viewmodels/PortfolioChartViewModelTest.kt | 40 ++++---- .../views/components/PerpetualChartSection.kt | 11 ++- .../position/PerpetualPositionNavScreen.kt | 4 +- .../views/position/PerpetualPositionScene.kt | 11 +-- .../viewmodels/PerpetualDetailsViewModel.kt | 25 +++-- .../android/ui/models/StateViewType.kt | 18 ++++ .../android/ui/models/chart/ChartFetch.kt | 19 ---- .../android/ui/models/chart/ChartViewState.kt | 8 -- .../ui/components/chart/ChartStateView.kt | 19 ++-- 17 files changed, 202 insertions(+), 201 deletions(-) create mode 100644 android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/AssetChartState.kt create mode 100644 android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/PortfolioState.kt create mode 100644 android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/StateViewType.kt delete mode 100644 android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt delete mode 100644 android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartViewState.kt diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt index f91ca6bac9..53187db9e0 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/Chart.kt @@ -16,57 +16,48 @@ import com.gemwallet.android.features.asset.viewmodels.chart.viewmodels.ChartVie import com.gemwallet.android.ui.components.chart.ChartStateView import com.gemwallet.android.ui.components.chart.GemLineChart import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel -import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.dataOrNull import com.wallet.core.primitives.ChartPeriod @Composable fun Chart(viewModel: ChartViewModel = hiltViewModel()) { - val uiModel by viewModel.chartUIModel.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() ChartSection( - uiModel = uiModel, state = state, onPeriodSelect = viewModel::setPeriod, - ) { selectedPoint -> chartHeader(uiModel, selectedPoint) } + ) { uiModel, selectedPoint -> chartHeader(uiModel, selectedPoint) } } @Composable internal fun ChartSection( - uiModel: ChartUIModel, state: ChartUIModel.State, onPeriodSelect: (ChartPeriod) -> Unit, periods: List = ChartPeriod.entries, - header: (PricePoint?) -> ChartHeaderUIModel?, + header: (ChartUIModel, PricePoint?) -> ChartHeaderUIModel?, ) { key(state.period) { var selectedIndex by remember { mutableStateOf(null) } - val displayState = when { - state.period != uiModel.period -> ChartViewState.Loading - else -> state.viewState - } - val chartPoints = uiModel.chartPoints - val selectedPoint: PricePoint? = if (displayState == ChartViewState.Ready) { - selectedIndex?.let { chartPoints.getOrNull(it) } - } else { - null + val uiModel = state.chart.dataOrNull + val selectedPoint = uiModel?.let { model -> + selectedIndex?.let { model.chartPoints.getOrNull(it) } } ChartStateView( - state = displayState, - header = header(selectedPoint), + state = state.chart, + header = uiModel?.let { header(it, selectedPoint) }, period = state.period, onPeriodSelect = onPeriodSelect, periods = periods, - ) { + ) { model -> GemLineChart( - points = uiModel.renderPoints, + points = model.renderPoints, lineColor = MaterialTheme.colorScheme.primary, selectedIndex = selectedIndex, onSelectionChanged = { selectedIndex = it }, - minLabel = uiModel.minLabel, - maxLabel = uiModel.maxLabel, + minLabel = model.minLabel, + maxLabel = model.maxLabel, ) } } diff --git a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt index 9a6a55c4aa..a78bdf8959 100644 --- a/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt +++ b/android/features/asset/presents/src/main/kotlin/com/gemwallet/android/features/asset/presents/chart/PortfolioChartScene.kt @@ -29,7 +29,7 @@ import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.TabsBar import com.gemwallet.android.ui.components.screen.Scene import com.gemwallet.android.ui.icons.AppIcons -import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.StateViewType import com.wallet.core.primitives.PortfolioChartType import com.wallet.core.primitives.PortfolioType @@ -78,7 +78,7 @@ fun PortfolioChartScene( ) { LazyColumn { item { PortfolioChart(viewModel) } - if (state.viewState == ChartViewState.Ready || state.viewState == ChartViewState.Empty) { + if (state.chart is StateViewType.Data || state.chart == StateViewType.NoData) { portfolioStatistics(currency, statistics) } } @@ -125,16 +125,14 @@ private fun ChartTypeSelector(selected: PortfolioChartType, onSelect: (Portfolio @Composable private fun PortfolioChart(viewModel: PortfolioChartViewModel) { - val uiModel by viewModel.chartUIModel.collectAsStateWithLifecycle() val state by viewModel.chartUIState.collectAsStateWithLifecycle() val periods by viewModel.availablePeriods.collectAsStateWithLifecycle() ChartSection( - uiModel = uiModel, state = state, onPeriodSelect = viewModel::setPeriod, periods = periods, - ) { selectedPoint -> portfolioChartHeader(uiModel, selectedPoint) } + ) { uiModel, selectedPoint -> portfolioChartHeader(uiModel, selectedPoint) } } private fun PortfolioType.titleRes(): Int = when (this) { diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/AssetChartState.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/AssetChartState.kt new file mode 100644 index 0000000000..e1498d70d8 --- /dev/null +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/AssetChartState.kt @@ -0,0 +1,12 @@ +package com.gemwallet.android.features.asset.viewmodels.chart.models + +import com.gemwallet.android.ui.models.StateViewType +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.ChartValue +import com.wallet.core.primitives.Currency + +internal data class AssetChartState( + val period: ChartPeriod, + val currency: Currency, + val prices: StateViewType> = StateViewType.Loading, +) diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt index 168958cee2..d8d04a333a 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/ChartUIModel.kt @@ -7,14 +7,17 @@ import com.gemwallet.android.model.AssetPriceInfo import com.gemwallet.android.model.CurrencyFormatter import com.gemwallet.android.model.PriceChangeFormatter import com.gemwallet.android.ui.components.chart.ChartPoint +import com.gemwallet.android.ui.models.StateViewType import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel import com.gemwallet.android.ui.models.chart.ChartValueType -import com.gemwallet.android.ui.models.chart.ChartViewState import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.ChartValue import com.wallet.core.primitives.Currency +internal const val MinChartPoints = 2 +internal const val StopTimeoutMillis = 5_000L + data class ChartUIModel( val period: ChartPeriod = ChartPeriod.Day, val currentPoint: PricePoint? = null, @@ -34,7 +37,7 @@ data class ChartUIModel( data class State( val period: ChartPeriod = ChartPeriod.Day, - val viewState: ChartViewState = ChartViewState.Loading, + val chart: StateViewType = StateViewType.Loading, ) } diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/PortfolioState.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/PortfolioState.kt new file mode 100644 index 0000000000..3920f3a42a --- /dev/null +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/models/PortfolioState.kt @@ -0,0 +1,22 @@ +package com.gemwallet.android.features.asset.viewmodels.chart.models + +import com.gemwallet.android.ui.models.StateViewType +import com.wallet.core.primitives.ChartDateValue +import com.wallet.core.primitives.ChartPeriod +import com.wallet.core.primitives.Currency +import com.wallet.core.primitives.PortfolioChartType +import com.wallet.core.primitives.PortfolioData +import com.wallet.core.primitives.PortfolioType + +internal data class PortfolioState( + val type: PortfolioType, + val period: ChartPeriod, + val currency: Currency, + val data: StateViewType = StateViewType.Loading, +) + +internal fun PortfolioData.chartValues(chartType: PortfolioChartType): List = + (charts.firstOrNull { it.chartType == chartType } ?: charts.firstOrNull())?.values.orEmpty() + +internal fun List.hasVariation(): Boolean = + size >= MinChartPoints && minOf { it.value } != maxOf { it.value } diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt index e25da70c5e..9a65dacd67 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModel.kt @@ -8,12 +8,13 @@ import com.gemwallet.android.application.assets.coordinators.GetAssetTokenInfo import com.gemwallet.android.application.assets.coordinators.GetChartPeriod import com.gemwallet.android.application.assets.coordinators.SetChartPeriod import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency +import com.gemwallet.android.features.asset.viewmodels.chart.models.AssetChartState import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel +import com.gemwallet.android.features.asset.viewmodels.chart.models.MinChartPoints +import com.gemwallet.android.features.asset.viewmodels.chart.models.StopTimeoutMillis import com.gemwallet.android.features.asset.viewmodels.chart.models.from -import com.gemwallet.android.ui.models.chart.ChartFetch -import com.gemwallet.android.ui.models.chart.ChartViewState -import com.gemwallet.android.ui.models.chart.MinChartPoints -import com.gemwallet.android.ui.models.chart.viewState +import com.gemwallet.android.ui.models.StateViewType +import com.gemwallet.android.ui.models.flatMap import com.gemwallet.android.ui.models.navigation.requireAssetId import com.wallet.core.primitives.AssetId import com.wallet.core.primitives.ChartPeriod @@ -31,9 +32,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -59,38 +59,39 @@ class ChartViewModel internal constructor( selectedPeriod, getCurrentCurrency.getCurrency().distinctUntilChanged(), refreshTrigger, - ) { period, currency, _ -> period to currency } - .mapLatest { (period, currency) -> + ) { period, currency, _ -> AssetChartState(period, currency) } + .transformLatest { state -> + emit(state) val prices = try { - request(period, currency) + request(state.period, state.currency) } catch (e: Exception) { currentCoroutineContext().ensureActive() null } refreshState.value = false - ChartFetch(request = period, data = prices) + val chartPrices = when { + prices == null -> StateViewType.Error + prices.size < MinChartPoints -> StateViewType.NoData + else -> StateViewType.Data(prices) + } + emit(state.copy(prices = chartPrices)) } .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(StopTimeoutMillis), + AssetChartState(selectedPeriod.value, Currency.USD), + ) - val chartUIState = combine(selectedPeriod, chartPrices) { period, fetch -> + val chartUIState = combine(assetPriceInfo, chartPrices) { priceInfo, state -> ChartUIModel.State( - period = period, - viewState = fetch.viewState(period) { prices -> - if (prices.size < MinChartPoints) ChartViewState.Empty else ChartViewState.Ready + period = state.period, + chart = state.prices.flatMap { + StateViewType.Data(ChartUIModel.from(it, priceInfo, state.period, state.currency)) }, ) }.stateIn(viewModelScope, SharingStarted.Eagerly, ChartUIModel.State()) - val chartUIModel = combine( - assetPriceInfo, - selectedPeriod, - chartPrices, - getCurrentCurrency.getCurrency().distinctUntilChanged(), - ) { priceInfo, period, fetch, currency -> - ChartUIModel.from(fetch?.takeIf { it.matches(period) }?.data.orEmpty(), priceInfo, period, currency) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChartUIModel()) - private suspend fun request(period: ChartPeriod, currency: Currency): List { return getAssetChartData.getAssetChartData(assetId, period, currency) } diff --git a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt index aebd22a738..5de5cb3361 100644 --- a/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt +++ b/android/features/asset/viewmodels/src/main/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModel.kt @@ -8,16 +8,17 @@ import com.gemwallet.android.application.assets.coordinators.walletChartPeriods import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet import com.gemwallet.android.features.asset.viewmodels.chart.models.ChartUIModel +import com.gemwallet.android.features.asset.viewmodels.chart.models.PortfolioState +import com.gemwallet.android.features.asset.viewmodels.chart.models.StopTimeoutMillis +import com.gemwallet.android.features.asset.viewmodels.chart.models.chartValues import com.gemwallet.android.features.asset.viewmodels.chart.models.from -import com.gemwallet.android.ui.models.chart.ChartFetch -import com.gemwallet.android.ui.models.chart.ChartViewState -import com.gemwallet.android.ui.models.chart.MinChartPoints -import com.gemwallet.android.ui.models.chart.viewState -import com.wallet.core.primitives.ChartDateValue +import com.gemwallet.android.features.asset.viewmodels.chart.models.hasVariation +import com.gemwallet.android.ui.models.StateViewType +import com.gemwallet.android.ui.models.dataOrNull +import com.gemwallet.android.ui.models.flatMap import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.Currency import com.wallet.core.primitives.PortfolioChartType -import com.wallet.core.primitives.PortfolioData import com.wallet.core.primitives.PortfolioType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -31,12 +32,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import javax.inject.Inject -private const val StopTimeoutMillis = 5_000L - @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class PortfolioChartViewModel internal constructor( @@ -66,57 +65,62 @@ class PortfolioChartViewModel internal constructor( selectedPeriod, getCurrentCurrency.getCurrency().distinctUntilChanged(), refreshTrigger, - ) { type, period, currency, _ -> Triple(type, period, currency) } - .mapLatest { (type, period, currency) -> + ) { type, period, currency, _ -> PortfolioState(type, period, currency) } + .transformLatest { state -> + emit(state) val data = try { - getPortfolioData.getPortfolioData(type, period, currency) + getPortfolioData.getPortfolioData(state.type, state.period, state.currency) } catch (e: Exception) { currentCoroutineContext().ensureActive() null } refreshState.value = false val periods = data?.availablePeriods.orEmpty() - if (data != null && periods.isNotEmpty() && !periods.contains(period)) { - selectedPeriod.compareAndSet(period, periods.first()) - null - } else { - ChartFetch( - request = PortfolioRequest(type, period), - data = data?.let { PortfolioResult(it, type.displayCurrency(currency)) }, - ) + when { + data == null -> emit(state.copy(data = StateViewType.Error)) + periods.isNotEmpty() && !periods.contains(state.period) -> + selectedPeriod.compareAndSet(state.period, periods.first()) + else -> emit(state.copy(data = StateViewType.Data(data))) } } .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), null) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(StopTimeoutMillis), + PortfolioState(initialType, selectedPeriod.value, Currency.USD), + ) - val chartUIState = combine(selectedType, selectedPeriod, selectedChartType, portfolio) { type, period, chartType, fetch -> + val chartUIState = combine(portfolio, selectedChartType) { state, chartType -> ChartUIModel.State( - period = period, - viewState = fetch.viewState(PortfolioRequest(type, period)) { it.portfolio.chartState(chartType) }, + period = state.period, + chart = state.data.flatMap { data -> + val values = data.chartValues(chartType) + if (values.hasVariation()) { + StateViewType.Data( + ChartUIModel.from( + values = values, + period = state.period, + currency = state.displayCurrency(), + showHeaderValue = state.type == PortfolioType.Wallet || chartType == PortfolioChartType.Value, + ), + ) + } else { + StateViewType.NoData + } + }, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel.State()) - val chartUIModel = combine(selectedType, selectedPeriod, selectedChartType, portfolio) { type, period, chartType, fetch -> - fetch?.takeIf { it.matches(PortfolioRequest(type, period)) }?.data?.let { result -> - ChartUIModel.from( - values = result.portfolio.chartValues(chartType), - period = period, - currency = result.currency, - showHeaderValue = type == PortfolioType.Wallet || chartType == PortfolioChartType.Value, - ) - } ?: ChartUIModel(period = period) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), ChartUIModel()) - val statistics = portfolio - .map { it?.data?.portfolio?.statistics.orEmpty() } + .map { it.data.dataOrNull?.statistics.orEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), emptyList()) val currency = portfolio - .map { it?.data?.currency ?: Currency.USD } + .map { it.displayCurrency() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), Currency.USD) val availablePeriods = portfolio - .map { it?.data?.portfolio?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: walletChartPeriods } + .map { it.data.dataOrNull?.availablePeriods?.takeIf { periods -> periods.isNotEmpty() } ?: walletChartPeriods } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(StopTimeoutMillis), walletChartPeriods) fun setType(type: PortfolioType) { @@ -150,19 +154,5 @@ class PortfolioChartViewModel internal constructor( ) } -private data class PortfolioRequest(val type: PortfolioType, val period: ChartPeriod) - -private class PortfolioResult(val portfolio: PortfolioData, val currency: Currency) - -private fun PortfolioData.chartValues(chartType: PortfolioChartType): List = - (charts.firstOrNull { it.chartType == chartType } ?: charts.firstOrNull())?.values.orEmpty() - -private fun PortfolioData.chartState(chartType: PortfolioChartType): ChartViewState { - val values = chartValues(chartType) - val hasVariation = values.size >= MinChartPoints && - values.minOf { it.value } != values.maxOf { it.value } - return if (hasVariation) ChartViewState.Ready else ChartViewState.Empty -} - -private fun PortfolioType.displayCurrency(fetchCurrency: Currency): Currency = - if (this == PortfolioType.Perpetuals) Currency.USD else fetchCurrency +private fun PortfolioState.displayCurrency(): Currency = + if (type == PortfolioType.Perpetuals) Currency.USD else currency diff --git a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModelTest.kt b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModelTest.kt index ca535f8635..fcbbda9654 100644 --- a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModelTest.kt +++ b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/ChartViewModelTest.kt @@ -11,7 +11,8 @@ import com.gemwallet.android.model.AssetInfo import com.gemwallet.android.testkit.mockAssetInfo import com.gemwallet.android.testkit.mockAssetSolanaUSDC import com.gemwallet.android.testkit.mockChartPrices -import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.StateViewType +import com.gemwallet.android.ui.models.dataOrNull import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.Currency import io.mockk.coEvery @@ -77,11 +78,11 @@ class ChartViewModelTest { coEvery { getAssetChartData.getAssetChartData(asset.id, ChartPeriod.Day, Currency.USD) } returns prices val viewModel = createViewModel(tokenInfoFlow) - val uiModel = viewModel.chartUIModel.first { it.chartPoints.size == prices.size } + val uiModel = viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == prices.size }.chart.dataOrNull!! assertEquals(prices.size, uiModel.chartPoints.size) assertNull(uiModel.currentPoint) - assertEquals(ChartViewState.Ready, viewModel.chartUIState.value.viewState) + assertEquals(true, viewModel.chartUIState.value.chart is StateViewType.Data) } @Test @@ -92,7 +93,7 @@ class ChartViewModelTest { coEvery { getAssetChartData.getAssetChartData(asset.id, ChartPeriod.Day, Currency.USD) } returns prices val viewModel = createViewModel(tokenInfoFlow) - val uiModel = viewModel.chartUIModel.first { it.chartPoints.size == prices.size } + val uiModel = viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == prices.size }.chart.dataOrNull!! assertEquals(prices.size, uiModel.chartPoints.size) assertNull(uiModel.currentPoint) @@ -106,13 +107,13 @@ class ChartViewModelTest { coEvery { getAssetChartData.getAssetChartData(asset.id, ChartPeriod.Day, Currency.USD) } returns prices val viewModel = createViewModel(tokenInfoFlow) - val uiModel = viewModel.chartUIModel.first { it.chartPoints.size == prices.size } + val uiModel = viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == prices.size }.chart.dataOrNull!! coVerify(exactly = 1) { getAssetChartData.getAssetChartData(asset.id, ChartPeriod.Day, Currency.USD) } assertEquals(prices.size, uiModel.chartPoints.size) - assertEquals(ChartViewState.Ready, viewModel.chartUIState.value.viewState) + assertEquals(true, viewModel.chartUIState.value.chart is StateViewType.Data) } @Test @@ -124,7 +125,7 @@ class ChartViewModelTest { coEvery { getAssetChartData.getAssetChartData(asset.id, ChartPeriod.Month, Currency.USD) } returns prices val viewModel = createViewModel(tokenInfoFlow) - viewModel.chartUIModel.first { it.chartPoints.size == prices.size } + viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == prices.size } assertEquals(ChartPeriod.Month, viewModel.chartUIState.value.period) coVerify(exactly = 1) { @@ -138,9 +139,9 @@ class ChartViewModelTest { val viewModel = createViewModel(tokenInfoFlow) viewModel.setPeriod(ChartPeriod.Month) - testScheduler.runCurrent() + val state = viewModel.chartUIState.first { it.period == ChartPeriod.Month } - assertEquals(ChartPeriod.Month, viewModel.chartUIState.value.period) + assertEquals(ChartPeriod.Month, state.period) verify(exactly = 1) { setChartPeriod(ChartPeriod.Month) } } diff --git a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt index 121c65fc6f..2eb9403602 100644 --- a/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt +++ b/android/features/asset/viewmodels/src/test/kotlin/com/gemwallet/android/features/asset/viewmodels/chart/viewmodels/PortfolioChartViewModelTest.kt @@ -5,7 +5,8 @@ import androidx.lifecycle.viewModelScope import com.gemwallet.android.application.assets.coordinators.GetPortfolioData import com.gemwallet.android.application.session.coordinators.GetCurrentCurrency import com.gemwallet.android.data.repositories.perpetual.ObservePerpetualWallet -import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.StateViewType +import com.gemwallet.android.ui.models.dataOrNull import com.wallet.core.primitives.ChartDateValue import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.ChartValuePercentage @@ -73,10 +74,9 @@ class PortfolioChartViewModelTest { coEvery { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(10f, 12f, 14f)) val viewModel = createViewModel() - val uiModel = viewModel.chartUIModel.first { it.chartPoints.size == 3 } + val state = viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == 3 } - assertEquals(3, uiModel.chartPoints.size) - assertEquals(ChartViewState.Ready, viewModel.chartUIState.first { it.viewState != ChartViewState.Loading }.viewState) + assertEquals(3, state.chart.dataOrNull?.chartPoints?.size) } @Test @@ -84,9 +84,9 @@ class PortfolioChartViewModelTest { coEvery { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(1f, 2f)) val viewModel = createViewModel() - viewModel.chartUIModel.first { it.chartPoints.size == 2 } + viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == 2 } - assertEquals(ChartPeriod.All, viewModel.chartUIState.first { it.viewState != ChartViewState.Loading }.period) + assertEquals(ChartPeriod.All, viewModel.chartUIState.first { it.chart != StateViewType.Loading }.period) coVerify(exactly = 1) { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } } @@ -97,12 +97,12 @@ class PortfolioChartViewModelTest { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.Month, Currency.USD) } returns portfolioData(listOf(1f, 2f, 3f)) val viewModel = createViewModel() - backgroundScope.launch { viewModel.chartUIModel.collect {} } + backgroundScope.launch { viewModel.chartUIState.collect {} } viewModel.setPeriod(ChartPeriod.Month) - val model = viewModel.chartUIModel.first { it.chartPoints.size == 3 } + val state = viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == 3 } - assertEquals(ChartPeriod.Month, model.period) + assertEquals(ChartPeriod.Month, state.period) coVerify { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.Month, Currency.USD) } } @@ -116,11 +116,11 @@ class PortfolioChartViewModelTest { getPortfolioData.getPortfolioData(any(), ChartPeriod.Day, any()) } returns portfolioData(listOf(1f, 2f, 3f), availablePeriods = periods) val viewModel = createViewModel() - backgroundScope.launch { viewModel.chartUIModel.collect {} } + backgroundScope.launch { viewModel.chartUIState.collect {} } - val model = viewModel.chartUIModel.first { it.chartPoints.size == 3 } + val state = viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == 3 } - assertEquals(ChartPeriod.Day, model.period) + assertEquals(ChartPeriod.Day, state.period) coVerify { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.Day, Currency.USD) } } @@ -130,9 +130,9 @@ class PortfolioChartViewModelTest { getPortfolioData.getPortfolioData(PortfolioType.Perpetuals, ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(1f, 2f)) val viewModel = createViewModel(initialType = PortfolioType.Perpetuals) - backgroundScope.launch { viewModel.chartUIModel.collect {} } + backgroundScope.launch { viewModel.chartUIState.collect {} } - viewModel.chartUIModel.first { it.chartPoints.size == 2 } + viewModel.chartUIState.first { it.chart.dataOrNull?.chartPoints?.size == 2 } assertEquals(PortfolioType.Perpetuals, viewModel.selectedType.value) coVerify(exactly = 0) { getPortfolioData.getPortfolioData(PortfolioType.Wallet, any(), any()) } @@ -142,11 +142,11 @@ class PortfolioChartViewModelTest { fun `shows error state when the portfolio request fails`() = runTest(testDispatcher) { coEvery { getPortfolioData.getPortfolioData(any(), any(), any()) } throws IllegalStateException("network down") val viewModel = createViewModel() - backgroundScope.launch { viewModel.chartUIModel.collect {} } + backgroundScope.launch { viewModel.chartUIState.collect {} } - val state = viewModel.chartUIState.first { it.viewState != ChartViewState.Loading } + val state = viewModel.chartUIState.first { it.chart != StateViewType.Loading } - assertEquals(ChartViewState.Error, state.viewState) + assertEquals(StateViewType.Error, state.chart) } @Test @@ -155,11 +155,11 @@ class PortfolioChartViewModelTest { getPortfolioData.getPortfolioData(PortfolioType.Wallet, ChartPeriod.All, Currency.USD) } returns portfolioData(listOf(5f, 5f, 5f)) val viewModel = createViewModel() - backgroundScope.launch { viewModel.chartUIModel.collect {} } + backgroundScope.launch { viewModel.chartUIState.collect {} } - val state = viewModel.chartUIState.first { it.viewState != ChartViewState.Loading } + val state = viewModel.chartUIState.first { it.chart != StateViewType.Loading } - assertEquals(ChartViewState.Empty, state.viewState) + assertEquals(StateViewType.NoData, state.chart) } @Test diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PerpetualChartSection.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PerpetualChartSection.kt index c9c1f6e028..22e7a1feea 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PerpetualChartSection.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/components/PerpetualChartSection.kt @@ -28,7 +28,8 @@ import com.gemwallet.android.ui.models.chart.CandlestickTooltipUIModel import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel import com.gemwallet.android.ui.models.chart.ChartReferenceLineRole import com.gemwallet.android.ui.models.chart.ChartReferenceLineUIModel -import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.StateViewType +import com.gemwallet.android.ui.models.dataOrNull import com.gemwallet.android.ui.theme.paddingSmall import com.wallet.core.primitives.ChartCandleStick import com.wallet.core.primitives.ChartPeriod @@ -38,8 +39,7 @@ private val TooltipRightSafeArea = 96.dp @Composable internal fun PerpetualChartSection( - data: List, - chartState: ChartViewState, + state: StateViewType>, period: ChartPeriod, entry: Double? = null, liquidation: Double? = null, @@ -48,6 +48,7 @@ internal fun PerpetualChartSection( onPeriodSelect: (ChartPeriod) -> Unit, modifier: Modifier = Modifier, ) { + val data = state.dataOrNull.orEmpty() var selectedIndex by remember(period) { mutableStateOf(null) } val safeSelectedIndex = selectedIndex?.takeIf { it in data.indices } val selectedCandle = safeSelectedIndex?.let { data[it] } @@ -99,12 +100,12 @@ internal fun PerpetualChartSection( } ChartStateView( - state = chartState, + state = state, header = headerUIModel, period = period, onPeriodSelect = onPeriodSelect, modifier = modifier, - ) { + ) { _ -> if (chartUIModel != null) { GemCandlestickChart( model = chartUIModel, diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt index 30b3ee09ba..5381fc10aa 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionNavScreen.kt @@ -39,7 +39,6 @@ fun PerpetualPositionNavScreen( val position by viewModel.position.collectAsStateWithLifecycle() val transactions by viewModel.transactions.collectAsStateWithLifecycle() val chart by viewModel.chart.collectAsStateWithLifecycle() - val chartState by viewModel.chartState.collectAsStateWithLifecycle() val period by viewModel.period.collectAsStateWithLifecycle() val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() var showAutoclose by remember { mutableStateOf(false) } @@ -48,8 +47,7 @@ fun PerpetualPositionNavScreen( perpetual = perpetual, position = position, transactions = transactions, - chartData = chart, - chartState = chartState, + chart = chart, period = period, isRefreshing = isRefreshing, onAction = { action -> diff --git a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt index 96b2bdf9bb..131d8896ff 100644 --- a/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt +++ b/android/features/perpetual/presents/src/main/kotlin/com/gemwallet/android/features/perpetual/views/position/PerpetualPositionScene.kt @@ -28,7 +28,7 @@ import com.gemwallet.android.features.perpetual.views.components.positionPropert import com.gemwallet.android.ui.R import com.gemwallet.android.ui.components.list_item.transaction.transactionsList import com.gemwallet.android.ui.components.screen.Scene -import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.StateViewType import com.gemwallet.android.ui.theme.WalletTheme import com.wallet.core.primitives.Asset import com.wallet.core.primitives.AssetId @@ -47,8 +47,7 @@ internal fun PerpetualPositionScene( perpetual: PerpetualDetailsDataAggregate?, position: PerpetualPositionDetailsDataAggregate?, transactions: List, - chartData: List, - chartState: ChartViewState, + chart: StateViewType>, period: ChartPeriod, isRefreshing: Boolean, onAction: (PerpetualDetailsAction) -> Unit, @@ -78,8 +77,7 @@ internal fun PerpetualPositionScene( ) { item { PerpetualChartSection( - data = chartData, - chartState = chartState, + state = chart, period = period, entry = position?.entryValue, liquidation = position?.liquidationValue, @@ -185,8 +183,7 @@ private fun PerpetualPositionScenePreview() { perpetual = samplePerpetual, position = samplePosition, transactions = emptyList(), - chartData = chartData, - chartState = ChartViewState.Ready, + chart = StateViewType.Data(chartData), period = ChartPeriod.Day, isRefreshing = false, onAction = {}, diff --git a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt index 60c13c74db..7ab003f533 100644 --- a/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt +++ b/android/features/perpetual/viewmodels/src/main/kotlin/com/gemwallet/android/features/perpetual/viewmodels/PerpetualDetailsViewModel.kt @@ -17,8 +17,9 @@ import com.gemwallet.android.application.transactions.coordinators.SyncAssetTran import com.gemwallet.android.application.transactions.coordinators.TransactionsRequestFilter import com.gemwallet.android.ui.models.actions.AmountTransactionAction import com.gemwallet.android.ui.models.actions.ConfirmTransactionAction -import com.gemwallet.android.ui.models.chart.ChartViewState +import com.gemwallet.android.ui.models.StateViewType import com.gemwallet.android.ui.models.navigation.requireAssetId +import com.wallet.core.primitives.ChartCandleStick import com.wallet.core.primitives.ChartPeriod import com.wallet.core.primitives.PerpetualDirection import com.wallet.core.primitives.TransactionType @@ -41,7 +42,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -108,39 +108,33 @@ class PerpetualDetailsViewModel @Inject constructor( val period = MutableStateFlow(getPerpetualChartPeriod()) - private val viewState = MutableStateFlow(ChartViewState.Loading) - val chartState: StateFlow = viewState.asStateFlow() - private val refreshTrigger = MutableStateFlow(0L) private val refreshState = MutableStateFlow(false) val isRefreshing: StateFlow = refreshState.asStateFlow() - val chart = combine(period, refreshTrigger) { period, _ -> period } - .onEach { viewState.value = ChartViewState.Loading } + val chart: StateFlow>> = combine(period, refreshTrigger) { period, _ -> period } .flatMapLatest { period -> flow { + emit(StateViewType.Loading) try { var candles = getPerpetualChartData.getPerpetualChartData(assetId, period) refreshState.value = false - emit(candles) + emit(candles.toChartState()) perpetualObserver.chartUpdates .filter { it.coin == perpetual.value?.coin && it.interval == period.hyperliquidInterval } .collect { update -> candles = candles.mergeCandle(update.candle) - emit(candles) + emit(candles.toChartState()) } } catch (e: Exception) { currentCoroutineContext().ensureActive() - viewState.value = ChartViewState.Error refreshState.value = false + emit(StateViewType.Error) } } } - .onEach { candles -> - viewState.value = if (candles.isEmpty()) ChartViewState.Empty else ChartViewState.Ready - } .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SubscriptionGraceMillis), emptyList()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SubscriptionGraceMillis), StateViewType.Loading) private val screenVisible = MutableStateFlow(false) @@ -223,3 +217,6 @@ class PerpetualDetailsViewModel @Inject constructor( } } } + +private fun List.toChartState(): StateViewType> = + if (isEmpty()) StateViewType.NoData else StateViewType.Data(this) diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/StateViewType.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/StateViewType.kt new file mode 100644 index 0000000000..cc00b88f62 --- /dev/null +++ b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/StateViewType.kt @@ -0,0 +1,18 @@ +package com.gemwallet.android.ui.models + +sealed interface StateViewType { + data object NoData : StateViewType + data object Loading : StateViewType + data class Data(val data: T) : StateViewType + data object Error : StateViewType +} + +val StateViewType.dataOrNull: T? + get() = (this as? StateViewType.Data)?.data + +fun StateViewType.flatMap(transform: (T) -> StateViewType): StateViewType = when (this) { + StateViewType.NoData -> StateViewType.NoData + StateViewType.Loading -> StateViewType.Loading + StateViewType.Error -> StateViewType.Error + is StateViewType.Data -> transform(data) +} diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt deleted file mode 100644 index f776603de4..0000000000 --- a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartFetch.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.gemwallet.android.ui.models.chart - -const val MinChartPoints = 2 - -class ChartFetch( - val request: Request, - val data: Data?, -) { - fun matches(request: Request): Boolean = this.request == request -} - -fun ChartFetch?.viewState( - request: Request, - dataState: (Data) -> ChartViewState, -): ChartViewState = when { - this?.matches(request) != true -> ChartViewState.Loading - data == null -> ChartViewState.Error - else -> dataState(data) -} diff --git a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartViewState.kt b/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartViewState.kt deleted file mode 100644 index ea1554211d..0000000000 --- a/android/ui-models/src/main/kotlin/com/gemwallet/android/ui/models/chart/ChartViewState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.gemwallet.android.ui.models.chart - -sealed interface ChartViewState { - data object Loading : ChartViewState - data object Empty : ChartViewState - data object Error : ChartViewState - data object Ready : ChartViewState -} diff --git a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt index 5963dc427d..9578f32cae 100644 --- a/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt +++ b/android/ui/src/main/kotlin/com/gemwallet/android/ui/components/chart/ChartStateView.kt @@ -18,22 +18,22 @@ import com.gemwallet.android.ui.components.PeriodsPanel import com.gemwallet.android.ui.components.empty.EmptyStateView import com.gemwallet.android.ui.components.progress.CircularProgressIndicator20 import com.gemwallet.android.ui.icons.AppIcons +import com.gemwallet.android.ui.models.StateViewType import com.gemwallet.android.ui.models.chart.ChartHeaderUIModel -import com.gemwallet.android.ui.models.chart.ChartViewState import com.gemwallet.android.ui.theme.chartFrameHeight import com.gemwallet.android.ui.theme.paddingSmall import com.gemwallet.android.ui.theme.space4 import com.wallet.core.primitives.ChartPeriod @Composable -fun ChartStateView( - state: ChartViewState, +fun ChartStateView( + state: StateViewType, header: ChartHeaderUIModel?, period: ChartPeriod, onPeriodSelect: (ChartPeriod) -> Unit, modifier: Modifier = Modifier, periods: List = ChartPeriod.entries, - chartBody: @Composable BoxScope.() -> Unit, + chartBody: @Composable BoxScope.(T) -> Unit, ) { Column( modifier = modifier.fillMaxWidth(), @@ -42,7 +42,7 @@ fun ChartStateView( Column( modifier = Modifier.fillMaxWidth().height(chartFrameHeight), ) { - header?.takeIf { state == ChartViewState.Ready }?.let { + header?.takeIf { state is StateViewType.Data }?.let { ChartHeader( model = it, modifier = Modifier.padding(top = paddingSmall, bottom = space4), @@ -50,25 +50,24 @@ fun ChartStateView( } Box(modifier = Modifier.fillMaxWidth().weight(1f)) { when (state) { - ChartViewState.Loading -> CircularProgressIndicator20( + StateViewType.Loading -> CircularProgressIndicator20( modifier = Modifier.align(Alignment.Center), color = MaterialTheme.colorScheme.primary, ) - ChartViewState.Empty -> EmptyStateView( + StateViewType.NoData -> EmptyStateView( modifier = Modifier.fillMaxSize(), title = stringResource(R.string.common_not_available), icon = painterResource(R.drawable.empty_activity), ) - ChartViewState.Error -> EmptyStateView( + StateViewType.Error -> EmptyStateView( modifier = Modifier.fillMaxSize(), title = stringResource(R.string.errors_no_data_available), iconVector = AppIcons.Warning, ) - ChartViewState.Ready -> chartBody() + is StateViewType.Data -> chartBody(state.data) } } } PeriodsPanel(period, onPeriodSelect, periods) } } -