From 0591fb4dafd1793030201236522dbd8075571376 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 12:49:42 -0400 Subject: [PATCH 1/9] Add Latest Post Summary card and per-post stats to New Stats New Stats had no per-post surface: old stats' Latest Post Summary insight and its post detail screen are both FluxC-backed and were never ported. The new wordpress-rs per-post stats endpoint makes the Compose versions possible, so both are added here. The endpoint is not published yet, so the build consumes wordpress-rs from a local composite build via `localWordPressRsPath`. That has to become a version bump before this can merge. --- WordPress/src/main/AndroidManifest.xml | 5 + .../android/ui/newstats/InsightsCardType.kt | 4 + .../android/ui/newstats/InsightsViewModel.kt | 9 +- .../android/ui/newstats/NewStatsActivity.kt | 59 ++- .../ui/newstats/components/StatsBarChart.kt | 71 ++++ .../newstats/components/StatsLabeledValue.kt | 41 ++ .../datasource/LatestPostDataSource.kt | 95 +++++ .../ui/newstats/datasource/StatsDataSource.kt | 93 +++++ .../datasource/StatsDataSourceImpl.kt | 98 +++++ .../ui/newstats/latestpost/LatestPostCard.kt | 291 ++++++++++++++ .../latestpost/LatestPostCardUiState.kt | 25 ++ .../latestpost/LatestPostViewModel.kt | 115 ++++++ .../poststats/PostStatsDetailActivity.kt | 376 ++++++++++++++++++ .../poststats/PostStatsDetailViewModel.kt | 108 +++++ .../repository/StatsLatestPostUseCase.kt | 75 ++++ .../ui/newstats/repository/StatsRepository.kt | 49 +++ .../ui/newstats/util/StatsFormatter.kt | 45 +++ WordPress/src/main/res/values/strings.xml | 1 + .../InsightsCardsConfigurationTest.kt | 8 +- ...nsightsCardsConfigurationRepositoryTest.kt | 100 ++--- config/gradle/included_builds.gradle | 10 + local-builds.gradle-example | 1 + 22 files changed, 1589 insertions(+), 90 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 6131afb24c14..53d025eebd62 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -142,6 +142,11 @@ android:theme="@style/WordPress.NoActionBar" android:exported="false" /> + + .needsSummary(): Boolean = any { it == InsightsCardType.ALL_TIME_STATS || diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 02f229bccab1..18895308c5a0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -67,6 +67,7 @@ import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.ui.PagePostCreationSourcesDetail import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.components.FeedbackDialog import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -108,8 +109,11 @@ import org.wordpress.android.ui.newstats.subscribers.SubscribersTabContent import android.widget.Toast import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsCard import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsViewModel +import org.wordpress.android.ui.newstats.latestpost.LatestPostCard +import org.wordpress.android.ui.newstats.latestpost.LatestPostViewModel import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayCard import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayViewModel +import org.wordpress.android.ui.newstats.poststats.PostStatsDetailActivity import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeCard import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeViewModel import org.wordpress.android.ui.newstats.yearinreview.YearInReviewCard @@ -189,7 +193,8 @@ class NewStatsActivity : BaseAppCompatActivity() { onStatsUrlClick = { url -> activityNavigator.openInCustomTab(this, url) }, - onPostItemClick = ::openPostDetailStats + onPostItemClick = ::openPostDetailStats, + onCreatePostClick = ::createNewPost ) } } @@ -208,6 +213,19 @@ class NewStatsActivity : BaseAppCompatActivity() { activityNavigator.openPostDetailStats(this, item.id, item.postType, item.title, item.url) } + private fun createNewPost() { + selectedSiteRepository.getSelectedSite()?.let { site -> + ActivityLauncher.addNewPostForResult( + this, + site, + false, + PagePostCreationSourcesDetail.POST_FROM_STATS, + -1, + null + ) + } + } + /** * Records the opt-out. Navigation is deferred until the feedback dialog is answered, since * finishing this activity straight away would tear the dialog down with it. @@ -306,7 +324,8 @@ private fun NewStatsScreen( showIntroBottomSheet: Boolean = false, onIntroDismissed: () -> Unit = {}, onStatsUrlClick: (String) -> Unit = {}, - onPostItemClick: (MostViewedItem) -> Unit = {} + onPostItemClick: (MostViewedItem) -> Unit = {}, + onCreatePostClick: () -> Unit = {} ) { val viewsStatsViewModel: ViewsStatsViewModel = viewModel() val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState() @@ -448,7 +467,8 @@ private fun NewStatsScreen( tab = tabs[page], viewsStatsViewModel = viewsStatsViewModel, onStatsUrlClick = onStatsUrlClick, - onPostItemClick = onPostItemClick + onPostItemClick = onPostItemClick, + onCreatePostClick = onCreatePostClick ) } } @@ -460,7 +480,8 @@ private fun StatsTabContent( tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel, onStatsUrlClick: (String) -> Unit = {}, - onPostItemClick: (MostViewedItem) -> Unit = {} + onPostItemClick: (MostViewedItem) -> Unit = {}, + onCreatePostClick: () -> Unit = {} ) { when (tab) { StatsTab.TRAFFIC -> TrafficTabContent( @@ -469,7 +490,8 @@ private fun StatsTabContent( onPostItemClick = onPostItemClick ) StatsTab.INSIGHTS -> InsightsTabContent( - onStatsUrlClick = onStatsUrlClick + onStatsUrlClick = onStatsUrlClick, + onCreatePostClick = onCreatePostClick ) StatsTab.SUBSCRIBERS -> SubscribersTabContent() } @@ -1121,8 +1143,10 @@ private fun InsightsTabContent( mostPopularDayViewModel: MostPopularDayViewModel = viewModel(), mostPopularTimeViewModel: MostPopularTimeViewModel = viewModel(), tagsAndCategoriesViewModel: TagsAndCategoriesViewModel = viewModel(), + latestPostViewModel: LatestPostViewModel = viewModel(), insightsViewModel: InsightsViewModel = viewModel(), - onStatsUrlClick: (String) -> Unit = {} + onStatsUrlClick: (String) -> Unit = {}, + onCreatePostClick: () -> Unit = {} ) { val context = LocalContext.current val yearInReviewUiState by yearInReviewViewModel.uiState.collectAsState() @@ -1130,6 +1154,7 @@ private fun InsightsTabContent( val mostPopularDayUiState by mostPopularDayViewModel.uiState.collectAsState() val mostPopularTimeUiState by mostPopularTimeViewModel.uiState.collectAsState() val tagsAndCategoriesUiState by tagsAndCategoriesViewModel.uiState.collectAsState() + val latestPostUiState by latestPostViewModel.uiState.collectAsState() val isRefreshing by insightsViewModel.isDataRefreshing.collectAsState() val pullToRefreshState = rememberPullToRefreshState() @@ -1145,6 +1170,9 @@ private fun InsightsTabContent( if (InsightsCardType.TAGS_AND_CATEGORIES in cardsToLoad) { tagsAndCategoriesViewModel.loadData() } + if (InsightsCardType.LATEST_POST in cardsToLoad) { + latestPostViewModel.loadData() + } } val onRetryData = remember { { insightsViewModel.fetchData() } } @@ -1218,6 +1246,11 @@ private fun InsightsTabContent( ) { tagsAndCategoriesViewModel.refresh() } + if (InsightsCardType.LATEST_POST + in visibleCards + ) { + latestPostViewModel.refresh() + } }, indicator = { PullToRefreshDefaults.Indicator( @@ -1269,6 +1302,20 @@ private fun InsightsTabContent( onMoveDown = { insightsViewModel.moveCardDown(cardType) }, onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) } ) + InsightsCardType.LATEST_POST -> LatestPostCard( + uiState = latestPostUiState, + onRemoveCard = { insightsViewModel.removeCard(cardType) }, + onRetry = { latestPostViewModel.refresh() }, + onPostClick = { postId -> + PostStatsDetailActivity.start(context, postId) + }, + onCreatePostClick = onCreatePostClick, + cardPosition = pos, + onMoveUp = { insightsViewModel.moveCardUp(cardType) }, + onMoveToTop = { insightsViewModel.moveCardToTop(cardType) }, + onMoveDown = { insightsViewModel.moveCardDown(cardType) }, + onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) } + ) InsightsCardType.MOST_POPULAR_DAY -> MostPopularDayCard( uiState = mostPopularDayUiState, onRemoveCard = { insightsViewModel.removeCard(cardType) }, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt new file mode 100644 index 000000000000..7e5d9b63c3c5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt @@ -0,0 +1,71 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +// Zero-value entries still get a sliver of a bar so the series reads as continuous. +private const val MIN_BAR_FRACTION = 0.02f + +/** + * A compact bar chart of a single series, scaled against its own largest value. Renders nothing + * when every value is zero. + */ +@Composable +fun StatsBarChart( + values: List, + height: Dp, + modifier: Modifier = Modifier, + barSpacing: Dp = 2.dp +) { + val maxValue = values.maxOrNull() ?: 0L + if (maxValue <= 0L) return + + val barColor = MaterialTheme.colorScheme.primary + + Row( + modifier = modifier + .fillMaxWidth() + .height(height), + horizontalArrangement = Arrangement.spacedBy(barSpacing), + verticalAlignment = Alignment.Bottom + ) { + values.forEach { value -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + val fraction = ( + value.toFloat() / maxValue.toFloat() + ).coerceIn(MIN_BAR_FRACTION, 1f) + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction) + .clip( + RoundedCornerShape( + topStart = 2.dp, + topEnd = 2.dp + ) + ) + .background(barColor) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt new file mode 100644 index 000000000000..8d65bf4b0cdd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt @@ -0,0 +1,41 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.wordpress.android.ui.newstats.util.formatStatValue + +/** + * A stat shown as a small label above its formatted value, for the side-by-side rows of + * views/likes/comments on the Latest Post card and the post stats screen. + */ +@Composable +fun StatsLabeledValue( + @StringRes labelResId: Int, + value: Long, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = stringResource(labelResId), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatStatValue(value), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt new file mode 100644 index 000000000000..9716246cfec1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt @@ -0,0 +1,95 @@ +package org.wordpress.android.ui.newstats.datasource + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostListParams +import uniffi.wp_api.PostStatus +import uniffi.wp_api.SparseAnyPostFieldWithViewContext +import uniffi.wp_api.WpApiParamOrder +import uniffi.wp_api.WpApiParamPostsOrderBy +import javax.inject.Inject + +/** + * Looks up the site's most recently published post. + * + * The stats API needs a post ID before it can return per-post views, and the WP.com stats surface + * has no posts endpoint of its own, so this goes through the site's REST API instead. + * + * [WpApiClientProvider.getWpApiClient] routes WP.com and Jetpack sites through the WP.com REST + * proxy using the account's OAuth token, so no application password is needed for the sites that + * have stats in the first place. + */ +class LatestPostDataSource @Inject constructor( + private val wpApiClientProvider: WpApiClientProvider +) { + suspend fun fetchLatestPublishedPost( + site: SiteModel + ): LatestPostLookupResult { + val params = PostListParams( + perPage = 1u, + order = WpApiParamOrder.DESC, + orderby = WpApiParamPostsOrderBy.DATE, + status = listOf(PostStatus.Publish) + ) + + // Only the ID is needed; without the field filter the response carries the post's whole + // rendered content, excerpt and taxonomy payload. + val result = wpApiClientProvider + .getWpApiClient(site) + .request { requestBuilder -> + requestBuilder.posts() + .filterListWithViewContext( + postEndpointType = + PostEndpointType.Posts, + params = params, + fields = listOf( + SparseAnyPostFieldWithViewContext + .ID + ) + ) + } + + return when (result) { + is WpRequestResult.Success -> + result.response.data + .firstOrNull()?.id + ?.let { + LatestPostLookupResult.Success(it) + } + ?: LatestPostLookupResult.NoPosts + else -> { + val message = ( + result as? WpRequestResult.WpError<*> + )?.errorMessage + ?: "Failed to fetch the latest post" + AppLog.e( + T.STATS, + "LatestPostDataSource: " + + "fetchLatestPublishedPost " + + "failed - $message" + ) + LatestPostLookupResult.Error(message) + } + } + } +} + +/** + * Result of the latest-post lookup. [NoPosts] is a success -- the site simply has nothing + * published yet -- and is shown as an empty state rather than an error. + */ +sealed class LatestPostLookupResult { + data class Success( + val postId: Long + ) : LatestPostLookupResult() + + data object NoPosts : LatestPostLookupResult() + + data class Error( + val message: String + ) : LatestPostLookupResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index bcac4ff6fd42..d422bcc3b53b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -245,6 +245,21 @@ interface StatsDataSource { max: Int = 10 ): StatsTagsDataResult + /** + * Fetches view stats for a single post. + * + * @param siteId The WordPress.com site ID + * @param postId The post ID + * @param recentDays How many trailing days of the daily view history to return. The endpoint + * returns the post's entire history, which can be thousands of entries for a long-lived post. + * @return Result containing the post's view data or an error + */ + suspend fun fetchPostViews( + siteId: Long, + postId: Long, + recentDays: Int + ): PostViewsDataResult + /** * Fetches subscriber count stats for a specific site. * @@ -764,6 +779,84 @@ data class TagData( val link: String? = null ) +/** + * Result wrapper for the per-post views fetch operation. + */ +sealed class PostViewsDataResult { + data class Success( + val data: PostViewsData + ) : PostViewsDataResult() + data class Error( + val errorType: StatsErrorType + ) : PostViewsDataResult() +} + +/** + * View stats for a single post, along with the post's own metadata. The API returns both in one + * response, so no separate post fetch is needed. + */ +data class PostViewsData( + val postId: Long, + val postTitle: String, + /** Publication date in the site's timezone (format: yyyy-MM-dd HH:mm:ss). */ + val postDate: String, + val totalViews: Long, + val likeCount: Long, + val commentCount: Long, + /** Daily view counts for the trailing window, oldest first. */ + val recentDailyViews: List, + /** The most recent weeks of daily views, most recent first. */ + val weeks: List, + /** Yearly totals, most recent year first. */ + val years: List, + /** Yearly averages, most recent year first. */ + val averages: List +) + +/** + * A week's view total and how it compares with the week before. + * + * [startDay] and [endDay] bound the week (format: yyyy-MM-dd); the final week may be partial. The + * API also breaks the week down per day, which nothing displays yet. + */ +data class PostViewsWeek( + val startDay: String, + val endDay: String, + val total: Long, + val change: PostViewsChange +) + +/** + * How a week's views compare with the week before. + */ +sealed class PostViewsChange { + data class Percentage( + val value: Double + ) : PostViewsChange() + + /** The previous week had no views, so the API reports an unbounded change. */ + data object Infinite : PostViewsChange() + + /** The week has no predecessor to compare against. */ + data object None : PostViewsChange() +} + +/** + * A year's view total. The API also breaks this down by month, which nothing displays yet. + */ +data class PostViewsYear( + val year: String, + val total: Long +) + +/** + * A year's average daily views. The API also breaks this down by month, which nothing displays yet. + */ +data class PostViewsYearAverage( + val year: String, + val overall: Double +) + /** * Result wrapper for stats subscribers fetch operation. */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 4e374950980f..648adb8a1016 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -21,6 +21,9 @@ import uniffi.wp_api.StatsRegionViewsPeriod import uniffi.wp_api.StatsDevicesParams import uniffi.wp_api.StatsDevicesPeriod import uniffi.wp_api.StatsInsightsParams +import uniffi.wp_api.StatsPostChange +import uniffi.wp_api.StatsPostResponse +import uniffi.wp_api.StatsPostWeek import uniffi.wp_api.StatsTagsParams import uniffi.wp_api.StatsSearchTermsParams import uniffi.wp_api.StatsSearchTermsPeriod @@ -1243,6 +1246,101 @@ class StatsDataSourceImpl @Inject constructor( } } + override suspend fun fetchPostViews( + siteId: Long, + postId: Long, + recentDays: Int + ): PostViewsDataResult { + // The endpoint takes no query params -- num, date and period are silently ignored -- so + // the trailing window is applied to the response instead. + val result = getOrCreateClient() + .request { requestBuilder -> + requestBuilder.statsPost() + .getStatsPost( + wpComSiteId = siteId.toULong(), + postId = postId + ) + } + + logResultType("fetchPostViews", result) + + return when (result) { + is WpRequestResult.Success -> { + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchPostViews success" + ) + PostViewsDataResult.Success( + mapToPostViewsData( + result.response.data, + recentDays + ) + ) + } + else -> logErrorAndReturn( + "fetchPostViews", + result + ) { + PostViewsDataResult.Error(it) + } + } + } + + private fun mapToPostViewsData( + response: StatsPostResponse, + recentDays: Int + ): PostViewsData = PostViewsData( + postId = response.post.id, + postTitle = response.post.title, + postDate = response.post.date, + totalViews = response.views.toLong(), + likeCount = response.likeCount.toLong(), + commentCount = response.discussion + .commentCount.toLong(), + // dailyViews is the post's whole history -- + // thousands of entries for an old post -- so only + // the trailing window is mapped. + recentDailyViews = response.dailyViews + .takeLast(recentDays) + .map { it.views.toLong() }, + // The API sends weeks oldest first; the UI lists + // the most recent week at the top. + weeks = response.weeks + .map { it.toPostViewsWeek() } + .reversed(), + years = response.years + .map { (year, value) -> + PostViewsYear( + year = year, + total = value.total.toLong() + ) + } + .sortedByDescending { it.year }, + averages = response.averages + .map { (year, value) -> + PostViewsYearAverage( + year = year, + overall = value.overall + ) + } + .sortedByDescending { it.year } + ) + + private fun StatsPostWeek.toPostViewsWeek() = + PostViewsWeek( + startDay = days.firstOrNull()?.day.orEmpty(), + endDay = days.lastOrNull()?.day.orEmpty(), + total = total.toLong(), + change = when (val change = change) { + is StatsPostChange.Percentage -> + PostViewsChange.Percentage(change.value) + is StatsPostChange.Infinite -> + PostViewsChange.Infinite + null -> PostViewsChange.None + } + ) + override suspend fun fetchStatsSubscribers( siteId: Long, quantity: Int, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt new file mode 100644 index 000000000000..b73a3fac3b5c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt @@ -0,0 +1,291 @@ +package org.wordpress.android.ui.newstats.latestpost + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.newstats.components.CardPosition +import org.wordpress.android.ui.newstats.components.StatsBarChart +import org.wordpress.android.ui.newstats.components.StatsCardContainer +import org.wordpress.android.ui.newstats.components.StatsCardErrorContent +import org.wordpress.android.ui.newstats.components.StatsCardHeader +import org.wordpress.android.ui.newstats.components.StatsLabeledValue +import org.wordpress.android.ui.newstats.util.ShimmerBox + +private val CardPadding = 16.dp +private val ChartHeight = 48.dp + +@Composable +@Suppress("LongParameterList") +fun LatestPostCard( + uiState: LatestPostCardUiState, + onRemoveCard: () -> Unit, + onRetry: () -> Unit, + onPostClick: (Long) -> Unit, + onCreatePostClick: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + when (uiState) { + is LatestPostCardUiState.Loading -> + LoadingContent() + is LatestPostCardUiState.NoData -> + NoDataContent( + onCreatePostClick, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is LatestPostCardUiState.Loaded -> + LoadedContent( + uiState, + onPostClick, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + LatestPostCardUiState.Error -> + StatsCardErrorContent( + titleResId = R.string + .stats_insights_latest_post_summary, + errorMessageResId = + R.string.stats_error_api, + onRetry = onRetry, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + ShimmerBar(width = 180.dp, height = 24.dp) + Spacer(modifier = Modifier.height(20.dp)) + ShimmerBar(width = 240.dp, height = 20.dp) + Spacer(modifier = Modifier.height(8.dp)) + ShimmerBar(width = 100.dp, height = 14.dp) + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + repeat(STAT_COLUMN_COUNT) { + ShimmerBar(width = 60.dp, height = 40.dp) + } + } + Spacer(modifier = Modifier.height(20.dp)) + ShimmerBar( + width = 240.dp, + height = ChartHeight + ) + } +} + +@Composable +private fun ShimmerBar(width: Dp, height: Dp) { + ShimmerBox( + modifier = Modifier + .width(width) + .height(height) + ) +} + +@Suppress("LongParameterList") +@Composable +private fun NoDataContent( + onCreatePostClick: () -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = R.string + .stats_insights_latest_post_summary, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource( + R.string.stats_insights_latest_post_empty + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + Button(onClick = onCreatePostClick) { + Text( + text = stringResource( + R.string.stats_insights_create_post + ) + ) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: LatestPostCardUiState.Loaded, + onPostClick: (Long) -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onPostClick(state.postId) } + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = R.string + .stats_insights_latest_post_summary, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = state.postTitle, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = state.postDate, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + StatsLabeledValue( + labelResId = R.string.stats_views, + value = state.views, + modifier = Modifier.weight(1f) + ) + StatsLabeledValue( + labelResId = R.string.stats_likes, + value = state.likes, + modifier = Modifier.weight(1f) + ) + StatsLabeledValue( + labelResId = R.string.stats_comments, + value = state.comments, + modifier = Modifier.weight(1f) + ) + } + if (state.recentViews.any { it > 0L }) { + Spacer(modifier = Modifier.height(16.dp)) + StatsBarChart( + values = state.recentViews, + height = ChartHeight, + barSpacing = 4.dp + ) + } + } +} + +private const val STAT_COLUMN_COUNT = 3 + +@Preview(showBackground = true) +@Composable +private fun LatestPostCardLoadedPreview() { + AppThemeM3 { + LatestPostCard( + uiState = LatestPostCardUiState.Loaded( + postId = 2729L, + postTitle = "Ten things I learned " + + "building a birdhouse", + postDate = "Aug 4, 2026", + views = 4600L, + likes = 32L, + comments = 7L, + recentViews = listOf( + 12L, 40L, 33L, 80L, 65L, 21L, 54L + ) + ), + onRemoveCard = {}, + onRetry = {}, + onPostClick = {}, + onCreatePostClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun LatestPostCardErrorPreview() { + AppThemeM3 { + LatestPostCard( + uiState = LatestPostCardUiState.Error, + onRemoveCard = {}, + onRetry = {}, + onPostClick = {}, + onCreatePostClick = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt new file mode 100644 index 000000000000..cb7f72621885 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.ui.newstats.latestpost + +sealed class LatestPostCardUiState { + data object Loading : LatestPostCardUiState() + + /** The site has no published posts yet. */ + data object NoData : LatestPostCardUiState() + + data class Loaded( + val postId: Long, + val postTitle: String, + val postDate: String, + val views: Long, + val likes: Long, + val comments: Long, + /** Daily views for the trailing week, oldest first. */ + val recentViews: List + ) : LatestPostCardUiState() + + /** + * The card shows a fixed error message and a retry action, so the underlying failure isn't + * carried here -- it's already logged at the point it happens. + */ + data object Error : LatestPostCardUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt new file mode 100644 index 000000000000..f720c555f30d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt @@ -0,0 +1,115 @@ +package org.wordpress.android.ui.newstats.latestpost + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.LatestPostResult +import org.wordpress.android.ui.newstats.repository.StatsLatestPostUseCase +import org.wordpress.android.ui.newstats.util.formatStatsDateTime +import org.wordpress.android.util.AppLog +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException +import javax.inject.Inject + +@HiltViewModel +class LatestPostViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val statsLatestPostUseCase: + StatsLatestPostUseCase +) : ViewModel() { + private val _uiState = + MutableStateFlow( + LatestPostCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val isLoaded = AtomicBoolean(false) + private val isLoading = AtomicBoolean(false) + // Main-thread-confined: only accessed from + // viewModelScope (Dispatchers.Main). + private var fetchJob: Job? = null + + fun loadData() { + if (isLoaded.get() || + !isLoading.compareAndSet(false, true) + ) return + fetchData() + } + + fun refresh() { + fetchJob?.cancel() + isLoaded.set(false) + isLoading.set(true) + _uiState.value = LatestPostCardUiState.Loading + fetchData() + } + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + private fun fetchData() { + val site = selectedSiteRepository + .getSelectedSite() + if (site == null) { + isLoading.set(false) + _uiState.value = LatestPostCardUiState.Error + return + } + + fetchJob = viewModelScope.launch { + try { + val result = + statsLatestPostUseCase(site) + isLoaded.set( + result !is LatestPostResult.Error + ) + handleResult(result) + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching latest post: " + + "${e.message}", + e + ) + isLoaded.set(false) + _uiState.value = + LatestPostCardUiState.Error + } finally { + isLoading.set(false) + } + } + } + + private fun handleResult(result: LatestPostResult) { + _uiState.value = when (result) { + is LatestPostResult.Success -> { + val views = result.data + LatestPostCardUiState.Loaded( + postId = views.postId, + postTitle = views.postTitle, + postDate = formatStatsDateTime( + views.postDate + ), + views = views.totalViews, + likes = views.likeCount, + comments = views.commentCount, + recentViews = views.recentDailyViews + ) + } + is LatestPostResult.NoPosts -> + LatestPostCardUiState.NoData + is LatestPostResult.Error -> + LatestPostCardUiState.Error + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt new file mode 100644 index 000000000000..648f4a06c49c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt @@ -0,0 +1,376 @@ +package org.wordpress.android.ui.newstats.poststats + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.ui.newstats.components.StatsBarChart +import org.wordpress.android.ui.newstats.components.StatsChangeIndicator +import org.wordpress.android.ui.newstats.components.StatsLabeledValue +import org.wordpress.android.ui.newstats.components.StatsViewChange +import org.wordpress.android.ui.newstats.datasource.PostViewsChange +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import org.wordpress.android.ui.newstats.datasource.PostViewsWeek +import org.wordpress.android.ui.newstats.util.ShimmerBox +import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.ui.newstats.util.formatStatsDate +import org.wordpress.android.ui.newstats.util.formatStatsDateTime + +private val ScreenPadding = 16.dp +private val ChartHeight = 120.dp +private const val LOADING_SHIMMER_ITEM_COUNT = 6 + +@AndroidEntryPoint +class PostStatsDetailActivity : BaseAppCompatActivity() { + private val viewModel: PostStatsDetailViewModel + by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val postId = intent.getLongExtra(ARG_POST_ID, 0L) + // The view model survives recreation, so only the first creation kicks off the fetch -- + // otherwise every rotation or theme change refetches the post's whole view history. + if (savedInstanceState == null) { + viewModel.loadData(postId) + } + + setContent { + AppThemeM3 { + val uiState by viewModel.uiState + .collectAsState() + PostStatsDetailScreen( + uiState = uiState, + onBackPressed = + onBackPressedDispatcher + ::onBackPressed, + onRetry = { viewModel.loadData(postId) } + ) + } + } + } + + companion object { + private const val ARG_POST_ID = "post_id" + + fun start(context: Context, postId: Long) { + context.startActivity( + Intent( + context, + PostStatsDetailActivity::class.java + ).putExtra(ARG_POST_ID, postId) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PostStatsDetailScreen( + uiState: PostStatsDetailUiState, + onBackPressed: () -> Unit, + onRetry: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource( + R.string.stats_post_detail_title + ) + ) + }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + imageVector = Icons + .AutoMirrored + .Filled.ArrowBack, + contentDescription = + stringResource( + R.string.back + ) + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when (uiState) { + is PostStatsDetailUiState.Loading -> + LoadingContent() + is PostStatsDetailUiState.Error -> + ErrorContent(uiState.message, onRetry) + is PostStatsDetailUiState.Loaded -> + LoadedContent(uiState.data) + } + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(ScreenPadding), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + repeat(LOADING_SHIMMER_ITEM_COUNT) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + ) + } + } +} + +@Composable +private fun ErrorContent( + message: String, + onRetry: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(ScreenPadding), + horizontalAlignment = + Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +private fun LoadedContent(data: PostViewsData) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(ScreenPadding) + ) { + item { PostHeader(data) } + item { Spacer(modifier = Modifier.height(24.dp)) } + + if (data.recentDailyViews.any { it > 0L }) { + item { + SectionTitle(R.string.stats_views) + StatsBarChart( + values = data.recentDailyViews, + height = ChartHeight + ) + Spacer( + modifier = Modifier.height(24.dp) + ) + } + } + + statsSection( + R.string.stats_detail_recent_weeks, + data.weeks + ) { WeekRow(it) } + + statsSection( + R.string.stats_detail_months_and_years, + data.years + ) { + DetailRow( + label = it.year, + value = formatStatValue(it.total) + ) + } + + statsSection( + R.string.stats_detail_average_views_per_day, + data.averages + ) { + DetailRow( + label = it.year, + value = formatStatValue(it.overall) + ) + } + } +} + +/** + * A titled list section, skipped entirely when [items] is empty. + */ +private fun LazyListScope.statsSection( + @StringRes titleResId: Int, + items: List, + row: @Composable (T) -> Unit +) { + if (items.isEmpty()) return + item { SectionTitle(titleResId) } + items(items.size) { index -> row(items[index]) } + item { Spacer(modifier = Modifier.height(24.dp)) } +} + +@Composable +private fun PostHeader(data: PostViewsData) { + Column { + Text( + text = data.postTitle, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatStatsDateTime(data.postDate), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(24.dp) + ) { + StatsLabeledValue( + labelResId = R.string.stats_views, + value = data.totalViews + ) + StatsLabeledValue( + labelResId = R.string.stats_likes, + value = data.likeCount + ) + StatsLabeledValue( + labelResId = R.string.stats_comments, + value = data.commentCount + ) + } + } +} + +@Composable +private fun SectionTitle(titleResId: Int) { + Text( + text = stringResource(titleResId), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) +} + +@Composable +private fun WeekRow(week: PostViewsWeek) { + DetailRow( + label = weekLabel(week), + value = formatStatValue(week.total) + ) { + when (val change = week.change) { + is PostViewsChange.Infinite -> Text( + text = INFINITY, + style = MaterialTheme.typography + .labelMedium, + color = MaterialTheme.colorScheme.primary + ) + is PostViewsChange.Percentage -> + StatsChangeIndicator( + change = change.value.toViewChange() + ) + is PostViewsChange.None -> Unit + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + trailing: @Composable () -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + trailing() + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp) + ) + } +} + +private fun weekLabel(week: PostViewsWeek): String { + val start = formatStatsDate(week.startDay) + val end = formatStatsDate(week.endDay) + return if (start == end) start else "$start - $end" +} + +// StatsChangeIndicator renders only the percentage, so the delta the sealed class also carries is +// not something this screen has -- a week's change arrives as a percentage, not a view count. +private fun Double.toViewChange(): StatsViewChange = when { + this > 0 -> StatsViewChange.Positive(0L, this) + this < 0 -> StatsViewChange.Negative(0L, -this) + else -> StatsViewChange.NoChange +} + +private const val INFINITY = "∞" diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt new file mode 100644 index 000000000000..2be88f15602e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt @@ -0,0 +1,108 @@ +package org.wordpress.android.ui.newstats.poststats + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import org.wordpress.android.ui.newstats.repository.PostViewsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.util.AppLog +import org.wordpress.android.viewmodel.ResourceProvider +import kotlin.coroutines.cancellation.CancellationException +import javax.inject.Inject + +@HiltViewModel +class PostStatsDetailViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val statsRepository: StatsRepository, + private val accountStore: AccountStore, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + PostStatsDetailUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + fun loadData(postId: Long) { + val site = selectedSiteRepository.getSelectedSite() + val token = accountStore.accessToken + if (site == null || token.isNullOrEmpty()) { + _uiState.value = PostStatsDetailUiState.Error( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + return + } + statsRepository.init(token) + + _uiState.value = PostStatsDetailUiState.Loading + viewModelScope.launch { + try { + val result = + statsRepository.fetchPostViews( + siteId = site.siteId, + postId = postId, + recentDays = RECENT_DAYS + ) + _uiState.value = when (result) { + is PostViewsResult.Success -> + PostStatsDetailUiState.Loaded( + result.data + ) + is PostViewsResult.Error -> + PostStatsDetailUiState.Error( + resourceProvider.getString( + R.string.stats_error_api + ) + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching post views: " + + "${e.message}", + e + ) + _uiState.value = + PostStatsDetailUiState.Error( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ) + } + } + } + + companion object { + // A month of daily views for the detail chart. + private const val RECENT_DAYS = 30 + } +} + +sealed class PostStatsDetailUiState { + data object Loading : PostStatsDetailUiState() + + data class Loaded( + val data: PostViewsData + ) : PostStatsDetailUiState() + + data class Error( + val message: String + ) : PostStatsDetailUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt new file mode 100644 index 000000000000..bb41acd10e2a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt @@ -0,0 +1,75 @@ +package org.wordpress.android.ui.newstats.repository + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.newstats.datasource.LatestPostDataSource +import org.wordpress.android.ui.newstats.datasource.LatestPostLookupResult +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import javax.inject.Inject + +/** + * Loads the site's newest published post together with its view stats. + * + * Two calls: the post ID comes from the site's REST API, the stats from WP.com. There is no + * caching here -- [org.wordpress.android.ui.newstats.latestpost.LatestPostViewModel] is the only + * consumer and already tracks whether it has loaded. + */ +class StatsLatestPostUseCase @Inject constructor( + private val statsRepository: StatsRepository, + private val latestPostDataSource: LatestPostDataSource, + private val accountStore: AccountStore +) { + suspend operator fun invoke( + site: SiteModel + ): LatestPostResult { + val token = accountStore.accessToken + if (token.isNullOrEmpty()) { + return LatestPostResult.Error("No access token") + } + statsRepository.init(token) + + return when ( + val lookup = latestPostDataSource + .fetchLatestPublishedPost(site) + ) { + is LatestPostLookupResult.NoPosts -> + LatestPostResult.NoPosts + is LatestPostLookupResult.Error -> + LatestPostResult.Error(lookup.message) + is LatestPostLookupResult.Success -> + when ( + val views = statsRepository + .fetchPostViews( + siteId = site.siteId, + postId = lookup.postId, + recentDays = CARD_RECENT_DAYS + ) + ) { + is PostViewsResult.Success -> + LatestPostResult.Success(views.data) + is PostViewsResult.Error -> + LatestPostResult.Error(views.message) + } + } + } + + companion object { + // A week of daily views is all the card charts. + private const val CARD_RECENT_DAYS = 7 + } +} + +/** + * Result of loading the latest post's stats. [NoPosts] means the site has nothing published yet. + */ +sealed class LatestPostResult { + data class Success( + val data: PostViewsData + ) : LatestPostResult() + + data object NoPosts : LatestPostResult() + + data class Error( + val message: String + ) : LatestPostResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 58f8144ceaa8..b5f56bc2b455 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -9,6 +9,8 @@ import org.wordpress.android.ui.newstats.datasource.ClicksDataResult import org.wordpress.android.ui.newstats.datasource.CountryViewsDataResult import org.wordpress.android.ui.newstats.datasource.DevicesDataResult import org.wordpress.android.ui.newstats.datasource.FileDownloadsDataResult +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import org.wordpress.android.ui.newstats.datasource.PostViewsDataResult import org.wordpress.android.ui.newstats.datasource.ReferrersDataResult import org.wordpress.android.ui.newstats.datasource.RegionViewsDataResult import org.wordpress.android.ui.newstats.datasource.SearchTermsDataResult @@ -1847,6 +1849,41 @@ class StatsRepository @Inject constructor( } } + /** + * Fetches view stats for a single post. + * + * @param recentDays How many trailing days of the daily + * view history to keep. The card shows a week, the + * detail screen a month. + */ + suspend fun fetchPostViews( + siteId: Long, + postId: Long, + recentDays: Int + ): PostViewsResult = withContext(ioDispatcher) { + val result = statsDataSource.fetchPostViews( + siteId = siteId, + postId = postId, + recentDays = recentDays + ) + when (result) { + is PostViewsDataResult.Success -> + PostViewsResult.Success( + data = result.data + ) + is PostViewsDataResult.Error -> { + appLogWrapper.e( + AppLog.T.STATS, + "Error fetching post views: " + + "${result.errorType}" + ) + PostViewsResult.Error( + result.errorType.name + ) + } + } + } + /** * Fetches all-time subscriber counts: current, 30d ago, * 60d ago, 90d ago. Makes 4 parallel API calls. @@ -2543,6 +2580,18 @@ sealed class TagsResult { ) : TagsResult() } +/** + * Result of fetching a single post's view stats. + */ +sealed class PostViewsResult { + data class Success( + val data: PostViewsData + ) : PostViewsResult() + data class Error( + val message: String + ) : PostViewsResult() +} + /** * Result wrapper for subscribers all-time stats fetch operation. */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index 17c38e036fb8..d730ec37428d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -2,7 +2,11 @@ package org.wordpress.android.ui.newstats.util import org.wordpress.android.R import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import java.util.Locale private const val THOUSAND = 1_000 @@ -11,6 +15,47 @@ private const val FORMAT_MILLION = "%.1fM" private const val FORMAT_THOUSAND = "%.1fK" private const val MONTH_ABBREVIATION_LENGTH = 3 +private const val DISPLAY_DATE_PATTERN = "MMM d, yyyy" +private val API_DATE_TIME_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss", Locale.US) + +/** + * Formats an API timestamp ("yyyy-MM-dd HH:mm:ss", site timezone) for display, e.g. "Aug 4, 2026". + * Returns the input unchanged if it can't be parsed. + */ +fun formatStatsDateTime(dateTime: String): String = parseOrLog(dateTime) { + LocalDateTime.parse(it, API_DATE_TIME_FORMAT) + .format(displayDateFormat()) +} + +/** + * Formats an API day ("yyyy-MM-dd") for display, e.g. "Aug 4, 2026". Returns the input unchanged + * if it can't be parsed. + */ +fun formatStatsDate(date: String): String = parseOrLog(date) { + LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) + .format(displayDateFormat()) +} + +private fun displayDateFormat() = DateTimeFormatter.ofPattern( + DISPLAY_DATE_PATTERN, + Locale.getDefault() +) + +@Suppress("TooGenericExceptionCaught") +private inline fun parseOrLog( + value: String, + format: (String) -> String +): String = try { + format(value) +} catch (e: Exception) { + AppLog.w( + AppLog.T.STATS, + "Failed to parse stats date '$value': ${e.message}" + ) + value +} + /** * Formats a stat value for display, using K/M suffixes for large numbers. * Examples: 1500 -> "1.5K", 2500000 -> "2.5M", 500 -> "500" diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 945176bfb3cd..36063af9be6a 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1629,6 +1629,7 @@ Avg. Views Per Day Recent Weeks Top Commentators + Post Stats Posts and Pages Referrers diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt index 5c408b9cd96e..7085f81e0270 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt @@ -19,12 +19,8 @@ class InsightsCardsConfigurationTest { ) assertThat(config.hiddenCards) - .containsExactlyInAnyOrder( - InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME, - InsightsCardType.YEAR_IN_REVIEW, - InsightsCardType.TAGS_AND_CATEGORIES + .containsExactlyInAnyOrderElementsOf( + InsightsCardType.entries ) } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt index 5ba6a74cd589..e39375162e74 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt @@ -89,12 +89,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.getConfiguration(TEST_SITE_ID) assertThat(config.visibleCards) - .containsExactly( - InsightsCardType.YEAR_IN_REVIEW, - InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME, - InsightsCardType.TAGS_AND_CATEGORIES + .containsExactlyInAnyOrderElementsOf( + InsightsCardType.entries ) verify(appPrefsWrapper) .setStatsInsightsCardsConfigurationJson( @@ -105,17 +101,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when saved config has all card types, then no update is saved`() = test { - val json = """ - { - "visibleCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val json = ALL_CARDS_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -127,12 +113,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.getConfiguration(TEST_SITE_ID) assertThat(config.visibleCards) - .containsExactly( - InsightsCardType.YEAR_IN_REVIEW, - InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME, - InsightsCardType.TAGS_AND_CATEGORIES + .containsExactlyInAnyOrderElementsOf( + InsightsCardType.entries ) verify( appPrefsWrapper, @@ -163,18 +145,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when addCard is called on empty config, then json is saved to prefs`() = test { - val emptyJson = """ - { - "visibleCards": [], - "hiddenCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val emptyJson = ALL_HIDDEN_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -267,18 +238,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when addCard is called, then card is added to visible cards`() = test { - val initialJson = """ - { - "visibleCards": [], - "hiddenCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val initialJson = ALL_HIDDEN_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -304,18 +264,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when mutation occurs, then configurationFlow emits site id and configuration`() = test { - val json = """ - { - "visibleCards": [], - "hiddenCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val json = ALL_HIDDEN_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -533,12 +482,11 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { jsonCaptor.firstValue, InsightsCardsConfiguration::class.java ) + val cards = InsightsCardType.entries assertThat(saved.visibleCards[1]) - .isEqualTo( - InsightsCardType.MOST_POPULAR_DAY - ) + .isEqualTo(cards[2]) assertThat(saved.visibleCards[2]) - .isEqualTo(InsightsCardType.ALL_TIME_STATS) + .isEqualTo(cards[1]) } @Test @@ -605,16 +553,20 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L - private val ALL_CARDS_JSON = """ - { - "visibleCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + // Derived from the enum so adding a card type can't leave this fixture stale -- + // loadAndMigrate() appends any missing type, which changes both the card list and + // whether a migration is persisted. + private val ALL_HIDDEN_JSON = InsightsCardType.entries + .joinToString( + separator = ",", + prefix = "{\"visibleCards\":[],\"hiddenCards\":[", + postfix = "]}" + ) { "\"${it.name}\"" } + private val ALL_CARDS_JSON = InsightsCardType.entries + .joinToString( + separator = ",", + prefix = "{\"visibleCards\":[", + postfix = "]}" + ) { "\"${it.name}\"" } } } diff --git a/config/gradle/included_builds.gradle b/config/gradle/included_builds.gradle index 07698de07ef2..8f1b260c0d98 100644 --- a/config/gradle/included_builds.gradle +++ b/config/gradle/included_builds.gradle @@ -11,6 +11,7 @@ gradle.ext.aztecAndroidPicassoLoaderPath = "org.wordpress.aztec:picasso-loader" gradle.ext.aboutAutomatticBinaryPath = "com.automattic:about" gradle.ext.gravatarBinaryPath = "com.gravatar:gravatar" gradle.ext.gravatarQuickEditorBinaryPath = "com.gravatar:gravatar-quickeditor" +gradle.ext.wordpressRsBinaryPath = "rs.wordpress.api:android" def localBuilds = new File("${rootDir}/local-builds.gradle") if (localBuilds.exists()) { @@ -86,4 +87,13 @@ if (localBuilds.exists()) { } } } + + if (ext.has("localWordPressRsPath")) { + includeBuild("$ext.localWordPressRsPath/native/kotlin") { + dependencySubstitution { + println "Substituting wordpress-rs with the local build" + substitute module("$gradle.ext.wordpressRsBinaryPath") using project(':api:android') + } + } + } } diff --git a/local-builds.gradle-example b/local-builds.gradle-example index 1b497c91eeab..72679cf82cd6 100644 --- a/local-builds.gradle-example +++ b/local-builds.gradle-example @@ -17,4 +17,5 @@ ext { //localGutenbergKitPath = "../GutenbergKit" //localAztecAndroidPath = "../AztecEditor-Android" //localGravatarAndroidPath = "../Gravatar-SDK-Android" + //localWordPressRsPath = "../wordpress-rs" } From 049a8d2ef2616b615465f41ddf60decfbc1ae881 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 13:48:03 -0400 Subject: [PATCH 2/9] Open Posts & Pages rows in the new post stats screen The tab had two post-detail destinations: the Latest Post card opened the Compose screen while Posts & Pages rows still opened the legacy one. The blocker was the "Home page / Archives" row, which reports id 0 and has no post behind it. wordpress-rs now serves stats for post id 0, returning no post, discussion or like_count for it, so `PostViewsData.post` is nullable and the home page shows its views without the post-only sections. Both row call sites route to the Compose screen, passing the row's title so the home page has a heading and posts have one while loading. --- .../android/ui/newstats/NewStatsActivity.kt | 12 ++- .../ui/newstats/datasource/StatsDataSource.kt | 22 ++++-- .../datasource/StatsDataSourceImpl.kt | 23 ++++-- .../latestpost/LatestPostViewModel.kt | 35 ++++++--- .../mostviewed/MostViewedDetailActivity.kt | 8 +- .../poststats/PostStatsDetailActivity.kt | 73 +++++++++++++------ 6 files changed, 124 insertions(+), 49 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 18895308c5a0..3787f9d8e2d3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -110,6 +110,7 @@ import android.widget.Toast import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsCard import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsViewModel import org.wordpress.android.ui.newstats.latestpost.LatestPostCard +import org.wordpress.android.ui.newstats.latestpost.LatestPostCardUiState import org.wordpress.android.ui.newstats.latestpost.LatestPostViewModel import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayCard import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayViewModel @@ -210,7 +211,8 @@ class NewStatsActivity : BaseAppCompatActivity() { } private fun openPostDetailStats(item: MostViewedItem) { - activityNavigator.openPostDetailStats(this, item.id, item.postType, item.title, item.url) + analyticsTracker.track(Stat.STATS_POSTS_AND_PAGES_ITEM_TAPPED) + PostStatsDetailActivity.start(this, item.id, item.title) } private fun createNewPost() { @@ -1307,7 +1309,13 @@ private fun InsightsTabContent( onRemoveCard = { insightsViewModel.removeCard(cardType) }, onRetry = { latestPostViewModel.refresh() }, onPostClick = { postId -> - PostStatsDetailActivity.start(context, postId) + PostStatsDetailActivity.start( + context, + postId, + (latestPostUiState as? + LatestPostCardUiState.Loaded) + ?.postTitle.orEmpty() + ) }, onCreatePostClick = onCreatePostClick, cardPosition = pos, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index d422bcc3b53b..5276a2862333 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -794,15 +794,12 @@ sealed class PostViewsDataResult { /** * View stats for a single post, along with the post's own metadata. The API returns both in one * response, so no separate post fetch is needed. + * + * Post ID 0 is the site's home page, which isn't a post -- it has view stats but no [post]. */ data class PostViewsData( val postId: Long, - val postTitle: String, - /** Publication date in the site's timezone (format: yyyy-MM-dd HH:mm:ss). */ - val postDate: String, val totalViews: Long, - val likeCount: Long, - val commentCount: Long, /** Daily view counts for the trailing window, oldest first. */ val recentDailyViews: List, /** The most recent weeks of daily views, most recent first. */ @@ -810,7 +807,20 @@ data class PostViewsData( /** Yearly totals, most recent year first. */ val years: List, /** Yearly averages, most recent year first. */ - val averages: List + val averages: List, + /** The post these stats belong to, or null for the site's home page. */ + val post: PostViewsPost? +) + +/** + * The editorial metadata of the post whose stats were fetched. + */ +data class PostViewsPost( + val title: String, + /** Publication date in the site's timezone (format: yyyy-MM-dd HH:mm:ss). */ + val date: String, + val likeCount: Long, + val commentCount: Long ) /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 648adb8a1016..c174f3715d39 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -1274,6 +1274,7 @@ class StatsDataSourceImpl @Inject constructor( PostViewsDataResult.Success( mapToPostViewsData( result.response.data, + postId, recentDays ) ) @@ -1289,15 +1290,12 @@ class StatsDataSourceImpl @Inject constructor( private fun mapToPostViewsData( response: StatsPostResponse, + postId: Long, recentDays: Int ): PostViewsData = PostViewsData( - postId = response.post.id, - postTitle = response.post.title, - postDate = response.post.date, + // The home page has no post row to read an id from, so the requested id is authoritative. + postId = response.post?.id ?: postId, totalViews = response.views.toLong(), - likeCount = response.likeCount.toLong(), - commentCount = response.discussion - .commentCount.toLong(), // dailyViews is the post's whole history -- // thousands of entries for an old post -- so only // the trailing window is mapped. @@ -1324,7 +1322,18 @@ class StatsDataSourceImpl @Inject constructor( overall = value.overall ) } - .sortedByDescending { it.year } + .sortedByDescending { it.year }, + // Null for the home page, which has view stats but no post metadata, likes or comments. + post = response.post?.let { post -> + PostViewsPost( + title = post.title, + date = post.date, + likeCount = response.likeCount + ?.toLong() ?: 0L, + commentCount = response.discussion + ?.commentCount?.toLong() ?: 0L + ) + } ) private fun StatsPostWeek.toPostViewsWeek() = diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt index f720c555f30d..30396c0ac22d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt @@ -94,17 +94,30 @@ class LatestPostViewModel @Inject constructor( _uiState.value = when (result) { is LatestPostResult.Success -> { val views = result.data - LatestPostCardUiState.Loaded( - postId = views.postId, - postTitle = views.postTitle, - postDate = formatStatsDateTime( - views.postDate - ), - views = views.totalViews, - likes = views.likeCount, - comments = views.commentCount, - recentViews = views.recentDailyViews - ) + val post = views.post + // The card always asks for a published post, so a missing post row means the + // response wasn't what we asked for -- treat it as an error rather than + // rendering a card with no title. + if (post == null) { + AppLog.w( + AppLog.T.STATS, + "Latest post stats had no post row " + + "for id ${views.postId}" + ) + LatestPostCardUiState.Error + } else { + LatestPostCardUiState.Loaded( + postId = views.postId, + postTitle = post.title, + postDate = formatStatsDateTime( + post.date + ), + views = views.totalViews, + likes = post.likeCount, + comments = post.commentCount, + recentViews = views.recentDailyViews + ) + } } is LatestPostResult.NoPosts -> LatestPostCardUiState.NoData diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt index 83c02b14e5d1..430bc6356ab0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.unit.dp import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.ui.newstats.poststats.PostStatsDetailActivity import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity @@ -49,6 +51,7 @@ import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.util.extensions.getParcelableArrayListCompat import org.wordpress.android.util.extensions.getSerializableCompat +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper import javax.inject.Inject private const val EXTRA_CARD_TYPE = "extra_card_type" @@ -72,6 +75,8 @@ private const val NO_EPOCH_DAY = -1L class MostViewedDetailActivity : BaseAppCompatActivity() { @Inject lateinit var activityNavigator: ActivityNavigator + @Inject lateinit var analyticsTracker: AnalyticsTrackerWrapper + private val viewModel: MostViewedDetailViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -114,7 +119,8 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { } private fun openPostDetailStats(item: MostViewedDetailItem) { - activityNavigator.openPostDetailStats(this, item.id, item.postType, item.title, item.url) + analyticsTracker.track(Stat.STATS_POSTS_AND_PAGES_ITEM_TAPPED) + PostStatsDetailActivity.start(this, item.id, item.title) } /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt index 648f4a06c49c..4d556522c05e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt @@ -66,6 +66,8 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { super.onCreate(savedInstanceState) val postId = intent.getLongExtra(ARG_POST_ID, 0L) + val postTitle = intent + .getStringExtra(ARG_POST_TITLE).orEmpty() // The view model survives recreation, so only the first creation kicks off the fetch -- // otherwise every rotation or theme change refetches the post's whole view history. if (savedInstanceState == null) { @@ -78,6 +80,7 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { .collectAsState() PostStatsDetailScreen( uiState = uiState, + postTitle = postTitle, onBackPressed = onBackPressedDispatcher ::onBackPressed, @@ -89,13 +92,25 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { companion object { private const val ARG_POST_ID = "post_id" + private const val ARG_POST_TITLE = "post_title" - fun start(context: Context, postId: Long) { + /** + * @param postId the post to show stats for; 0 is the site's home page + * @param postTitle shown until the response arrives, and kept as the title for the home + * page, whose stats carry no post metadata + */ + fun start( + context: Context, + postId: Long, + postTitle: String + ) { context.startActivity( Intent( context, PostStatsDetailActivity::class.java - ).putExtra(ARG_POST_ID, postId) + ) + .putExtra(ARG_POST_ID, postId) + .putExtra(ARG_POST_TITLE, postTitle) ) } } @@ -105,6 +120,7 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { @Composable private fun PostStatsDetailScreen( uiState: PostStatsDetailUiState, + postTitle: String, onBackPressed: () -> Unit, onRetry: () -> Unit ) { @@ -145,7 +161,7 @@ private fun PostStatsDetailScreen( is PostStatsDetailUiState.Error -> ErrorContent(uiState.message, onRetry) is PostStatsDetailUiState.Loaded -> - LoadedContent(uiState.data) + LoadedContent(uiState.data, postTitle) } } } @@ -197,12 +213,15 @@ private fun ErrorContent( } @Composable -private fun LoadedContent(data: PostViewsData) { +private fun LoadedContent( + data: PostViewsData, + fallbackTitle: String +) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(ScreenPadding) ) { - item { PostHeader(data) } + item { PostHeader(data, fallbackTitle) } item { Spacer(modifier = Modifier.height(24.dp)) } if (data.recentDailyViews.any { it > 0L }) { @@ -259,22 +278,30 @@ private fun LazyListScope.statsSection( item { Spacer(modifier = Modifier.height(24.dp)) } } +/** + * The title, date and engagement counts belong to the post. The home page has none of them, so it + * falls back to the title the caller supplied and shows views alone. + */ @Composable -private fun PostHeader(data: PostViewsData) { +private fun PostHeader(data: PostViewsData, fallbackTitle: String) { + val post = data.post Column { Text( - text = data.postTitle, + text = post?.title?.takeIf { it.isNotBlank() } + ?: fallbackTitle, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = formatStatsDateTime(data.postDate), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) + if (post != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatStatsDateTime(post.date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -285,14 +312,16 @@ private fun PostHeader(data: PostViewsData) { labelResId = R.string.stats_views, value = data.totalViews ) - StatsLabeledValue( - labelResId = R.string.stats_likes, - value = data.likeCount - ) - StatsLabeledValue( - labelResId = R.string.stats_comments, - value = data.commentCount - ) + if (post != null) { + StatsLabeledValue( + labelResId = R.string.stats_likes, + value = post.likeCount + ) + StatsLabeledValue( + labelResId = R.string.stats_comments, + value = post.commentCount + ) + } } } } From 197952f2ce68df8902bd815a3279ffac7e63e176 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 14:11:59 -0400 Subject: [PATCH 3/9] Bring the post stats screen closer to the old one Comparing the two screens side by side, the new one had drifted: it abbreviated from 1,000 where old stats abbreviates from 10,000 and groups below that, so 1,986 views read as "2.0K"; its tables had no column headers or dividers; and its chart had no axes at all. It also showed all-time views where old stats shows a selected day. The screen now tracks a selected day with arrows to step through the history, and reports that day's views and the change against the day before, over a two-week chart with value and date axes and the day highlighted. Serving the selected day needs the full view history rather than a trailing window, which lets `recentDays` drop out of the data source, repository and use case; the card takes its own trailing week instead. --- .../newstats/components/StatsDayViewsChart.kt | 119 +++++++++++ .../ui/newstats/datasource/StatsDataSource.kt | 20 +- .../datasource/StatsDataSourceImpl.kt | 25 +-- .../latestpost/LatestPostViewModel.kt | 9 +- .../poststats/PostStatsDetailActivity.kt | 201 ++++++++++++++++-- .../poststats/PostStatsDetailViewModel.kt | 67 +++++- .../repository/StatsLatestPostUseCase.kt | 8 +- .../ui/newstats/repository/StatsRepository.kt | 12 +- .../ui/newstats/util/StatsFormatter.kt | 32 +++ WordPress/src/main/res/values/strings.xml | 2 + 10 files changed, 432 insertions(+), 63 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt new file mode 100644 index 000000000000..de3b56ee8544 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt @@ -0,0 +1,119 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.data.columnModel +import com.patrykandpatrick.vico.compose.cartesian.layer.ColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.common.Fill +import com.patrykandpatrick.vico.compose.common.component.LineComponent +import org.wordpress.android.ui.newstats.StatsColors + +private const val BAR_CORNER_PERCENT = 20 +private val BarThickness = 16.dp + +/** + * A daily-views bar chart with value and date axes, highlighting one bar as the selected day. + * + * The highlight is drawn as a second stacked series that is zero everywhere except the selected + * index, which is how [org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard] layers its + * columns too -- Vico colours a series, not an individual bar. + */ +@Composable +fun StatsDayViewsChart( + values: List, + selectedIndex: Int, + height: Dp, + modifier: Modifier = Modifier, + bottomAxisLabel: (Int) -> String = { it.toString() } +) { + if (values.isEmpty()) return + + val modelProducer = remember { CartesianChartModelProducer() } + + LaunchedEffect(values, selectedIndex) { + modelProducer.runTransaction { + columnModel { + series( + values.mapIndexed { index, value -> + if (index == selectedIndex) 0L else value + } + ) + series( + values.mapIndexed { index, value -> + if (index == selectedIndex) value else 0L + } + ) + } + } + } + + val barShape = RoundedCornerShape( + topStartPercent = BAR_CORNER_PERCENT, + topEndPercent = BAR_CORNER_PERCENT + ) + val barColor = MaterialTheme.colorScheme.primary + // Vico rejects blank labels; only the first and last x get one, via the item placer. + val bottomAxisValueFormatter = remember(bottomAxisLabel) { + CartesianValueFormatter { _, x, _ -> + bottomAxisLabel(x.toInt()) + } + } + val endsOnlyItemPlacer = remember(values.size) { + HorizontalAxis.ItemPlacer.aligned( + spacing = { values.lastIndex.coerceAtLeast(1) } + ) + } + + CartesianChartHost( + chart = rememberCartesianChart( + rememberColumnCartesianLayer( + columnProvider = ColumnCartesianLayer + .ColumnProvider.series( + LineComponent( + fill = Fill(barColor), + thickness = BarThickness, + shape = barShape + ), + LineComponent( + fill = Fill( + StatsColors.ChangeBadgeNegative + ), + thickness = BarThickness, + shape = barShape + ) + ), + mergeMode = { + ColumnCartesianLayer.MergeMode.Stacked + } + ), + startAxis = VerticalAxis.rememberStart(line = null), + bottomAxis = HorizontalAxis.rememberBottom( + valueFormatter = bottomAxisValueFormatter, + itemPlacer = endsOnlyItemPlacer + ) + ), + modelProducer = modelProducer, + scrollState = rememberVicoScrollState( + scrollEnabled = false + ), + modifier = modifier + .fillMaxWidth() + .height(height) + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index 5276a2862333..5a488dec71ed 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -249,15 +249,12 @@ interface StatsDataSource { * Fetches view stats for a single post. * * @param siteId The WordPress.com site ID - * @param postId The post ID - * @param recentDays How many trailing days of the daily view history to return. The endpoint - * returns the post's entire history, which can be thousands of entries for a long-lived post. + * @param postId The post ID; 0 is the site's home page * @return Result containing the post's view data or an error */ suspend fun fetchPostViews( siteId: Long, - postId: Long, - recentDays: Int + postId: Long ): PostViewsDataResult /** @@ -800,8 +797,8 @@ sealed class PostViewsDataResult { data class PostViewsData( val postId: Long, val totalViews: Long, - /** Daily view counts for the trailing window, oldest first. */ - val recentDailyViews: List, + /** The post's complete daily view history, oldest first. */ + val dailyViews: List, /** The most recent weeks of daily views, most recent first. */ val weeks: List, /** Yearly totals, most recent year first. */ @@ -812,6 +809,15 @@ data class PostViewsData( val post: PostViewsPost? ) +/** + * A single day's view count. The day is kept so the chart can label its range. + */ +data class PostViewsDailyView( + /** The day the views were recorded on (format: yyyy-MM-dd). */ + val day: String, + val views: Long +) + /** * The editorial metadata of the post whose stats were fetched. */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index c174f3715d39..4583fc8fe1ff 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -1248,11 +1248,9 @@ class StatsDataSourceImpl @Inject constructor( override suspend fun fetchPostViews( siteId: Long, - postId: Long, - recentDays: Int + postId: Long ): PostViewsDataResult { - // The endpoint takes no query params -- num, date and period are silently ignored -- so - // the trailing window is applied to the response instead. + // The endpoint takes no query params -- num, date and period are silently ignored. val result = getOrCreateClient() .request { requestBuilder -> requestBuilder.statsPost() @@ -1274,8 +1272,7 @@ class StatsDataSourceImpl @Inject constructor( PostViewsDataResult.Success( mapToPostViewsData( result.response.data, - postId, - recentDays + postId ) ) } @@ -1290,18 +1287,18 @@ class StatsDataSourceImpl @Inject constructor( private fun mapToPostViewsData( response: StatsPostResponse, - postId: Long, - recentDays: Int + postId: Long ): PostViewsData = PostViewsData( // The home page has no post row to read an id from, so the requested id is authoritative. postId = response.post?.id ?: postId, totalViews = response.views.toLong(), - // dailyViews is the post's whole history -- - // thousands of entries for an old post -- so only - // the trailing window is mapped. - recentDailyViews = response.dailyViews - .takeLast(recentDays) - .map { it.views.toLong() }, + dailyViews = response.dailyViews + .map { + PostViewsDailyView( + day = it.period, + views = it.views.toLong() + ) + }, // The API sends weeks oldest first; the UI lists // the most recent week at the top. weeks = response.weeks diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt index 30396c0ac22d..c51c0324b0e3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt @@ -115,7 +115,9 @@ class LatestPostViewModel @Inject constructor( views = views.totalViews, likes = post.likeCount, comments = post.commentCount, - recentViews = views.recentDailyViews + recentViews = views.dailyViews + .takeLast(CARD_CHART_DAYS) + .map { it.views } ) } } @@ -125,4 +127,9 @@ class LatestPostViewModel @Inject constructor( LatestPostCardUiState.Error } } + + companion object { + // A week of daily views is all the card charts. + private const val CARD_CHART_DAYS = 7 + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt index 4d556522c05e..ce32d24e2be2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt @@ -20,8 +20,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -41,15 +44,19 @@ import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity -import org.wordpress.android.ui.newstats.components.StatsBarChart +import org.wordpress.android.ui.newstats.StatsColors +import org.wordpress.android.ui.newstats.components.StatsDayViewsChart import org.wordpress.android.ui.newstats.components.StatsChangeIndicator +import org.wordpress.android.ui.newstats.components.StatsListHeader import org.wordpress.android.ui.newstats.components.StatsLabeledValue import org.wordpress.android.ui.newstats.components.StatsViewChange import org.wordpress.android.ui.newstats.datasource.PostViewsChange import org.wordpress.android.ui.newstats.datasource.PostViewsData import org.wordpress.android.ui.newstats.datasource.PostViewsWeek import org.wordpress.android.ui.newstats.util.ShimmerBox -import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.ui.newstats.util.formatChangePercentage +import org.wordpress.android.ui.newstats.util.formatStatAverage +import org.wordpress.android.ui.newstats.util.formatStatCount import org.wordpress.android.ui.newstats.util.formatStatsDate import org.wordpress.android.ui.newstats.util.formatStatsDateTime @@ -84,7 +91,9 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { onBackPressed = onBackPressedDispatcher ::onBackPressed, - onRetry = { viewModel.loadData(postId) } + onRetry = { viewModel.loadData(postId) }, + onPreviousDay = viewModel::selectPreviousDay, + onNextDay = viewModel::selectNextDay ) } } @@ -118,11 +127,14 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("LongParameterList") private fun PostStatsDetailScreen( uiState: PostStatsDetailUiState, postTitle: String, onBackPressed: () -> Unit, - onRetry: () -> Unit + onRetry: () -> Unit, + onPreviousDay: () -> Unit, + onNextDay: () -> Unit ) { Scaffold( topBar = { @@ -161,7 +173,12 @@ private fun PostStatsDetailScreen( is PostStatsDetailUiState.Error -> ErrorContent(uiState.message, onRetry) is PostStatsDetailUiState.Loaded -> - LoadedContent(uiState.data, postTitle) + LoadedContent( + state = uiState, + fallbackTitle = postTitle, + onPreviousDay = onPreviousDay, + onNextDay = onNextDay + ) } } } @@ -214,9 +231,12 @@ private fun ErrorContent( @Composable private fun LoadedContent( - data: PostViewsData, - fallbackTitle: String + state: PostStatsDetailUiState.Loaded, + fallbackTitle: String, + onPreviousDay: () -> Unit, + onNextDay: () -> Unit ) { + val data = state.data LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(ScreenPadding) @@ -224,13 +244,15 @@ private fun LoadedContent( item { PostHeader(data, fallbackTitle) } item { Spacer(modifier = Modifier.height(24.dp)) } - if (data.recentDailyViews.any { it > 0L }) { + if (state.selectedDay != null) { item { - SectionTitle(R.string.stats_views) - StatsBarChart( - values = data.recentDailyViews, - height = ChartHeight + DaySelector( + state = state, + onPreviousDay = onPreviousDay, + onNextDay = onNextDay ) + SelectedDayViews(state) + DayViewsChart(state) Spacer( modifier = Modifier.height(24.dp) ) @@ -248,7 +270,7 @@ private fun LoadedContent( ) { DetailRow( label = it.year, - value = formatStatValue(it.total) + value = formatStatCount(it.total) ) } @@ -258,7 +280,7 @@ private fun LoadedContent( ) { DetailRow( label = it.year, - value = formatStatValue(it.overall) + value = formatStatAverage(it.overall) ) } } @@ -273,8 +295,19 @@ private fun LazyListScope.statsSection( row: @Composable (T) -> Unit ) { if (items.isEmpty()) return - item { SectionTitle(titleResId) } - items(items.size) { index -> row(items[index]) } + item { + SectionTitle(titleResId) + StatsListHeader( + leftHeaderResId = + R.string.stats_months_and_years_period_label, + rightHeaderResId = + R.string.stats_months_and_years_views_label + ) + } + items(items.size) { index -> + if (index > 0) HorizontalDivider() + row(items[index]) + } item { Spacer(modifier = Modifier.height(24.dp)) } } @@ -326,6 +359,140 @@ private fun PostHeader(data: PostViewsData, fallbackTitle: String) { } } +/** + * The selected day with arrows to step through the history, mirroring the old stats date bar. + */ +@Composable +private fun DaySelector( + state: PostStatsDetailUiState.Loaded, + onPreviousDay: () -> Unit, + onNextDay: () -> Unit +) { + val day = state.selectedDay ?: return + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatStatsDate(day.day), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = onPreviousDay, + enabled = state.hasPreviousDay + ) { + Icon( + imageVector = Icons.AutoMirrored + .Filled.KeyboardArrowLeft, + contentDescription = stringResource( + R.string.stats_previous_day + ) + ) + } + IconButton( + onClick = onNextDay, + enabled = state.hasNextDay + ) { + Icon( + imageVector = Icons.AutoMirrored + .Filled.KeyboardArrowRight, + contentDescription = stringResource( + R.string.stats_next_day + ) + ) + } + } +} + +/** + * The selected day's views and how they compare with the day before. + */ +@Composable +private fun SelectedDayViews( + state: PostStatsDetailUiState.Loaded +) { + val views = state.selectedDay?.views ?: return + val previous = state.previousDay?.views + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = formatStatCount(views), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.stats_views), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + modifier = Modifier + .padding(start = 8.dp, bottom = 2.dp) + .weight(1f) + ) + if (previous != null) { + DayChangeLabel(views = views, previous = previous) + } + } +} + +@Composable +private fun DayChangeLabel(views: Long, previous: Long) { + val difference = views - previous + val percentage = when { + previous == views -> "0%" + previous == 0L -> INFINITY + else -> formatChangePercentage( + difference.toDouble() / previous + ) + } + val positive = difference >= 0 + Text( + text = stringResource( + if (positive) { + R.string.stats_traffic_increase + } else { + R.string.stats_traffic_change + }, + formatStatCount(difference), + percentage + ), + style = MaterialTheme.typography.bodyMedium, + color = if (positive) { + StatsColors.ChangeBadgePositive + } else { + StatsColors.ChangeBadgeNegative + } + ) +} + +@Composable +private fun DayViewsChart( + state: PostStatsDetailUiState.Loaded +) { + val days = state.chartDays + if (days.isEmpty()) return + StatsDayViewsChart( + values = days.map { it.views }, + selectedIndex = days.lastIndex, + height = ChartHeight, + bottomAxisLabel = { index -> + days.getOrNull(index)?.day + ?.let(::formatStatsDate) + ?: days.last().day.let(::formatStatsDate) + }, + modifier = Modifier.padding(top = 8.dp) + ) +} + @Composable private fun SectionTitle(titleResId: Int) { Text( @@ -341,7 +508,7 @@ private fun SectionTitle(titleResId: Int) { private fun WeekRow(week: PostViewsWeek) { DetailRow( label = weekLabel(week), - value = formatStatValue(week.total) + value = formatStatCount(week.total) ) { when (val change = week.change) { is PostViewsChange.Infinite -> Text( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt index 2be88f15602e..74890ada40eb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt @@ -6,11 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.datasource.PostViewsData +import org.wordpress.android.ui.newstats.datasource.PostViewsDailyView import org.wordpress.android.ui.newstats.repository.PostViewsResult import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.util.AppLog @@ -56,13 +58,15 @@ class PostStatsDetailViewModel @Inject constructor( val result = statsRepository.fetchPostViews( siteId = site.siteId, - postId = postId, - recentDays = RECENT_DAYS + postId = postId ) _uiState.value = when (result) { is PostViewsResult.Success -> PostStatsDetailUiState.Loaded( - result.data + data = result.data, + // The newest day is selected first, as the old stats screen does. + selectedDayIndex = result.data + .dailyViews.lastIndex ) is PostViewsResult.Error -> PostStatsDetailUiState.Error( @@ -89,9 +93,24 @@ class PostStatsDetailViewModel @Inject constructor( } } - companion object { - // A month of daily views for the detail chart. - private const val RECENT_DAYS = 30 + /** Moves the selected day one step towards the start of the history. */ + fun selectPreviousDay() = shiftSelectedDay(-1) + + /** Moves the selected day one step towards the present. */ + fun selectNextDay() = shiftSelectedDay(1) + + private fun shiftSelectedDay(offset: Int) { + _uiState.update { state -> + if (state !is PostStatsDetailUiState.Loaded) { + return@update state + } + val target = state.selectedDayIndex + offset + if (target in state.data.dailyViews.indices) { + state.copy(selectedDayIndex = target) + } else { + state + } + } } } @@ -99,10 +118,42 @@ sealed class PostStatsDetailUiState { data object Loading : PostStatsDetailUiState() data class Loaded( - val data: PostViewsData - ) : PostStatsDetailUiState() + val data: PostViewsData, + /** Index into [PostViewsData.dailyViews]; -1 when the post has no history. */ + val selectedDayIndex: Int + ) : PostStatsDetailUiState() { + val selectedDay: PostViewsDailyView? + get() = data.dailyViews + .getOrNull(selectedDayIndex) + + /** The day before the selected one, which the change is measured against. */ + val previousDay: PostViewsDailyView? + get() = data.dailyViews + .getOrNull(selectedDayIndex - 1) + + val hasPreviousDay: Boolean + get() = selectedDayIndex > 0 + + val hasNextDay: Boolean + get() = selectedDayIndex < + data.dailyViews.lastIndex + + /** + * The window of days the chart draws, ending at the selected day so it is always the + * rightmost bar -- the same framing the old stats screen uses. + */ + val chartDays: List + get() = data.dailyViews + .take(selectedDayIndex + 1) + .takeLast(CHART_DAYS) + } data class Error( val message: String ) : PostStatsDetailUiState() + + companion object { + // Two weeks of bars, matching the old stats post detail chart. + const val CHART_DAYS = 14 + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt index bb41acd10e2a..2748241818ba 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt @@ -41,8 +41,7 @@ class StatsLatestPostUseCase @Inject constructor( val views = statsRepository .fetchPostViews( siteId = site.siteId, - postId = lookup.postId, - recentDays = CARD_RECENT_DAYS + postId = lookup.postId ) ) { is PostViewsResult.Success -> @@ -52,11 +51,6 @@ class StatsLatestPostUseCase @Inject constructor( } } } - - companion object { - // A week of daily views is all the card charts. - private const val CARD_RECENT_DAYS = 7 - } } /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index b5f56bc2b455..4e5d75aa1fb3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -1850,21 +1850,15 @@ class StatsRepository @Inject constructor( } /** - * Fetches view stats for a single post. - * - * @param recentDays How many trailing days of the daily - * view history to keep. The card shows a week, the - * detail screen a month. + * Fetches view stats for a single post, or for the site's home page when [postId] is 0. */ suspend fun fetchPostViews( siteId: Long, - postId: Long, - recentDays: Int + postId: Long ): PostViewsResult = withContext(ioDispatcher) { val result = statsDataSource.fetchPostViews( siteId = siteId, - postId = postId, - recentDays = recentDays + postId = postId ) when (result) { is PostViewsDataResult.Success -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index d730ec37428d..14712e02c025 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -4,6 +4,7 @@ import org.wordpress.android.R import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider +import java.text.NumberFormat import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -68,6 +69,37 @@ fun formatStatValue(value: Long): String { } } +private const val TEN_THOUSAND = 10_000 + +/** + * Formats a stat value for a detail list, where precision matters more than width: grouped in full + * up to 10,000 ("1,986"), abbreviated above it ("74.3K"). Matches the thresholds the old stats + * screens use; [formatStatValue] is the compact form for cards. + */ +fun formatStatCount(value: Long): String = if (value < TEN_THOUSAND) { + NumberFormat.getIntegerInstance(Locale.getDefault()) + .format(value) +} else { + formatStatValue(value) +} + +/** + * Formats a fractional change as a percentage, e.g. -0.25 -> "-25%". + */ +fun formatChangePercentage(fraction: Double): String = + NumberFormat.getPercentInstance(Locale.getDefault()) + .apply { maximumFractionDigits = 0 } + .format(fraction) + +/** + * Formats an average for a detail list, keeping one decimal place when there is one. + */ +fun formatStatAverage(value: Double): String = + NumberFormat.getInstance(Locale.getDefault()).apply { + maximumFractionDigits = 1 + minimumFractionDigits = 0 + }.format(value) + private const val FORMAT_DECIMAL = "%.1f" fun formatStatValue(value: Double): String { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 36063af9be6a..a7130bf44bdd 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1630,6 +1630,8 @@ Recent Weeks Top Commentators Post Stats + Previous day + Next day Posts and Pages Referrers From fe56b092cf97b91aacbf00f8f096dd4f3062f688 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 14:35:08 -0400 Subject: [PATCH 4/9] Remove the now-unused old-stats post detail navigation Routing Posts & Pages rows to the Compose screen left ActivityNavigator.openPostDetailStats with no callers. It was added a week ago for exactly those two call sites, and it carried the navigator's only use of SelectedSiteRepository along with statsDetailItemType and that helper's test. Removing it drops the DI edge and takes away a public method that silently opened the old stats screen, one autocomplete away from a third entry point. --- .../wordpress/android/ui/ActivityNavigator.kt | 36 +------------------ .../android/ui/StatsDetailItemTypeTest.kt | 25 ------------- 2 files changed, 1 insertion(+), 60 deletions(-) delete mode 100644 WordPress/src/test/java/org/wordpress/android/ui/StatsDetailItemTypeTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ActivityNavigator.kt b/WordPress/src/main/java/org/wordpress/android/ui/ActivityNavigator.kt index 1d5c9f4f0cea..c816730ad768 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/ActivityNavigator.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/ActivityNavigator.kt @@ -30,11 +30,8 @@ import org.wordpress.android.ui.domains.management.purchasedomain.PurchaseDomain import org.wordpress.android.ui.main.WPMainActivity import org.wordpress.android.ui.media.MediaBrowserActivity import org.wordpress.android.ui.media.MediaBrowserType -import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.mysite.menu.MenuActivity import org.wordpress.android.ui.mysite.personalization.PersonalizationActivity -import org.wordpress.android.ui.stats.StatsConstants -import org.wordpress.android.ui.stats.refresh.lists.detail.StatsDetailActivity import org.wordpress.android.ui.sitemonitor.SiteMonitorParentActivity import org.wordpress.android.ui.sitemonitor.SiteMonitorType import org.wordpress.android.util.AppLog @@ -44,9 +41,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ActivityNavigator @Inject constructor( - private val selectedSiteRepository: SelectedSiteRepository -) { +class ActivityNavigator @Inject constructor() { fun navigateToCampaignListingPage(context: Context, campaignListingPageSource: CampaignListingPageSource) { context.startActivity( Intent(context, BlazeCampaignParentActivity::class.java).apply { @@ -277,26 +272,6 @@ class ActivityNavigator @Inject constructor( .build() } - /** - * Opens the per-post stats detail screen for a Posts & Pages item tapped in the new stats. - * Shows an error toast when no site is selected. - */ - fun openPostDetailStats(context: Context, postId: Long, postType: String?, postTitle: String, postUrl: String?) { - val site = selectedSiteRepository.getSelectedSite() ?: run { - ToastUtils.showToast(context, R.string.blog_not_found, ToastUtils.Duration.SHORT) - return - } - AnalyticsTracker.track(AnalyticsTracker.Stat.STATS_POSTS_AND_PAGES_ITEM_TAPPED) - StatsDetailActivity.start( - context = context, - site = site, - postId = postId, - postType = statsDetailItemType(postType), - postTitle = postTitle, - postUrl = postUrl - ) - } - fun navigateToApplicationPasswordReauthentication(activity: Activity, authenticationUrl: String) { val intent = Intent(activity, ApplicationPasswordReauthenticateDialogActivity::class.java) intent.putExtra(ApplicationPasswordDialogActivity.EXTRA_SITE_URL, authenticationUrl) @@ -309,12 +284,3 @@ class ActivityNavigator @Inject constructor( ) } } - -/** - * Maps the API post type of a Posts & Pages item to the item type expected by the post detail - * stats screen, mirroring the old stats mapping (pages and the homepage share one detail layout). - */ -internal fun statsDetailItemType(postType: String?): String = when (postType) { - StatsConstants.ITEM_TYPE_POST, StatsConstants.ITEM_TYPE_ATTACHMENT -> postType - else -> StatsConstants.ITEM_TYPE_HOME_PAGE -} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/StatsDetailItemTypeTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/StatsDetailItemTypeTest.kt deleted file mode 100644 index 8e2f22717f98..000000000000 --- a/WordPress/src/test/java/org/wordpress/android/ui/StatsDetailItemTypeTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.wordpress.android.ui - -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test -import org.wordpress.android.ui.stats.StatsConstants - -class StatsDetailItemTypeTest { - @Test - fun `post type maps to post detail item type`() { - assertThat(statsDetailItemType("post")).isEqualTo(StatsConstants.ITEM_TYPE_POST) - } - - @Test - fun `attachment type maps to attachment detail item type`() { - assertThat(statsDetailItemType("attachment")).isEqualTo(StatsConstants.ITEM_TYPE_ATTACHMENT) - } - - @Test - fun `page, homepage, unknown and null types map to home page detail item type`() { - assertThat(statsDetailItemType("page")).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - assertThat(statsDetailItemType("homepage")).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - assertThat(statsDetailItemType("other")).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - assertThat(statsDetailItemType(null)).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - } -} From 77f68387c58c11c56e6be6ea0588db4fb25bd0ce Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 14:53:26 -0400 Subject: [PATCH 5/9] Clean up the post stats screen Two things were visible. The header formatted views through StatsLabeledValue, which abbreviated from 1,000, while the tables below abbreviated from 10,000, so 4,600 lifetime views read as "4.6K" in one place and "4,600" in another. formatStatValue now takes the threshold as a parameter, the way old stats' toFormattedString does, and the screen applies one threshold throughout. The chart window was derived from the selected day, which forced the highlight onto the last bar and shrank the chart to four bars near the start of a post's history. It is now a fixed trailing window with the highlight moving inside it and the arrows clamped to it, as the old screen does. That also retires a chartDays getter that copied the whole view history on every read to keep fourteen entries. The rest is smaller: the chart's values and axis labels are remembered so they stop invalidating Vico's own remembers and no date parsing happens on the draw path; the selected bar has its own colour token instead of borrowing the one that means "views went down"; the card passes its title to the click handler rather than having the caller downcast the state; and post id 0, the home page, no longer doubles as the missing-extra default. --- .../android/ui/newstats/NewStatsActivity.kt | 7 +- .../android/ui/newstats/StatsColors.kt | 6 ++ .../ui/newstats/components/StatsBarChart.kt | 4 +- .../newstats/components/StatsDayViewsChart.kt | 63 +++++++------ .../newstats/components/StatsLabeledValue.kt | 9 +- .../ui/newstats/latestpost/LatestPostCard.kt | 15 ++- .../poststats/PostStatsDetailActivity.kt | 93 ++++++++++++------- .../poststats/PostStatsDetailViewModel.kt | 56 +++++------ .../ui/newstats/util/StatsFormatter.kt | 49 +++++----- 9 files changed, 177 insertions(+), 125 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 3787f9d8e2d3..e44c2fc5f344 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -110,7 +110,6 @@ import android.widget.Toast import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsCard import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsViewModel import org.wordpress.android.ui.newstats.latestpost.LatestPostCard -import org.wordpress.android.ui.newstats.latestpost.LatestPostCardUiState import org.wordpress.android.ui.newstats.latestpost.LatestPostViewModel import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayCard import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayViewModel @@ -1308,13 +1307,11 @@ private fun InsightsTabContent( uiState = latestPostUiState, onRemoveCard = { insightsViewModel.removeCard(cardType) }, onRetry = { latestPostViewModel.refresh() }, - onPostClick = { postId -> + onPostClick = { postId, title -> PostStatsDetailActivity.start( context, postId, - (latestPostUiState as? - LatestPostCardUiState.Loaded) - ?.postTitle.orEmpty() + title ) }, onCreatePostClick = onCreatePostClick, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt index 3fbbf58c4b20..316c875356c0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt @@ -9,4 +9,10 @@ import androidx.compose.ui.graphics.Color object StatsColors { val ChangeBadgePositive = Color(0xFF2E7D32) val ChangeBadgeNegative = Color(0xFFE91E63) + + /** + * The selected bar in a day-views chart. Shares the negative badge's hue, but it marks the + * user's selection, not a decline -- keep them separate so either can move independently. + */ + val ChartSelectedBar = Color(0xFFE91E63) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt index 7e5d9b63c3c5..19a9a375e8d2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt @@ -28,8 +28,8 @@ private const val MIN_BAR_FRACTION = 0.02f fun StatsBarChart( values: List, height: Dp, - modifier: Modifier = Modifier, - barSpacing: Dp = 2.dp + barSpacing: Dp, + modifier: Modifier = Modifier ) { val maxValue = values.maxOrNull() ?: 0L if (maxValue <= 0L) return diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt index de3b56ee8544..ca57c4b156fc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt @@ -30,17 +30,17 @@ private val BarThickness = 16.dp /** * A daily-views bar chart with value and date axes, highlighting one bar as the selected day. * - * The highlight is drawn as a second stacked series that is zero everywhere except the selected - * index, which is how [org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard] layers its - * columns too -- Vico colours a series, not an individual bar. + * Vico colours a series rather than an individual bar, so the highlight is a second stacked series + * that is zero everywhere except the selected index. */ @Composable fun StatsDayViewsChart( values: List, selectedIndex: Int, height: Dp, - modifier: Modifier = Modifier, - bottomAxisLabel: (Int) -> String = { it.toString() } + startLabel: String, + endLabel: String, + modifier: Modifier = Modifier ) { if (values.isEmpty()) return @@ -63,17 +63,36 @@ fun StatsDayViewsChart( } } - val barShape = RoundedCornerShape( - topStartPercent = BAR_CORNER_PERCENT, - topEndPercent = BAR_CORNER_PERCENT - ) val barColor = MaterialTheme.colorScheme.primary - // Vico rejects blank labels; only the first and last x get one, via the item placer. - val bottomAxisValueFormatter = remember(bottomAxisLabel) { - CartesianValueFormatter { _, x, _ -> - bottomAxisLabel(x.toInt()) - } + val highlightColor = StatsColors.ChartSelectedBar + // These are remember() keys for the layer and the chart, so rebuilding them every + // recomposition would rebuild the whole chart with them. + val columnProvider = remember(barColor, highlightColor) { + val barShape = RoundedCornerShape( + topStartPercent = BAR_CORNER_PERCENT, + topEndPercent = BAR_CORNER_PERCENT + ) + ColumnCartesianLayer.ColumnProvider.series( + LineComponent( + fill = Fill(barColor), + thickness = BarThickness, + shape = barShape + ), + LineComponent( + fill = Fill(highlightColor), + thickness = BarThickness, + shape = barShape + ) + ) } + // Vico rejects blank labels, so the item placer -- not the formatter -- decides that only + // the two ends are labelled. + val bottomAxisValueFormatter = + remember(startLabel, endLabel) { + CartesianValueFormatter { _, x, _ -> + if (x <= 0.0) startLabel else endLabel + } + } val endsOnlyItemPlacer = remember(values.size) { HorizontalAxis.ItemPlacer.aligned( spacing = { values.lastIndex.coerceAtLeast(1) } @@ -83,21 +102,7 @@ fun StatsDayViewsChart( CartesianChartHost( chart = rememberCartesianChart( rememberColumnCartesianLayer( - columnProvider = ColumnCartesianLayer - .ColumnProvider.series( - LineComponent( - fill = Fill(barColor), - thickness = BarThickness, - shape = barShape - ), - LineComponent( - fill = Fill( - StatsColors.ChangeBadgeNegative - ), - thickness = BarThickness, - shape = barShape - ) - ), + columnProvider = columnProvider, mergeMode = { ColumnCartesianLayer.MergeMode.Stacked } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt index 8d65bf4b0cdd..3e30f2bba4e7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt @@ -11,17 +11,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.wordpress.android.ui.newstats.util.THOUSAND import org.wordpress.android.ui.newstats.util.formatStatValue /** * A stat shown as a small label above its formatted value, for the side-by-side rows of * views/likes/comments on the Latest Post card and the post stats screen. + * + * [abbreviateFrom] is passed through to [formatStatValue] so a screen can apply one abbreviation + * threshold to its header and its tables alike. */ @Composable fun StatsLabeledValue( @StringRes labelResId: Int, value: Long, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + abbreviateFrom: Int = THOUSAND ) { Column(modifier = modifier) { Text( @@ -32,7 +37,7 @@ fun StatsLabeledValue( ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = formatStatValue(value), + text = formatStatValue(value, abbreviateFrom), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt index b73a3fac3b5c..c45d5bb62bb2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt @@ -39,7 +39,7 @@ fun LatestPostCard( uiState: LatestPostCardUiState, onRemoveCard: () -> Unit, onRetry: () -> Unit, - onPostClick: (Long) -> Unit, + onPostClick: (postId: Long, title: String) -> Unit, onCreatePostClick: () -> Unit, modifier: Modifier = Modifier, cardPosition: CardPosition? = null, @@ -180,7 +180,7 @@ private fun NoDataContent( @Composable private fun LoadedContent( state: LatestPostCardUiState.Loaded, - onPostClick: (Long) -> Unit, + onPostClick: (postId: Long, title: String) -> Unit, onRemoveCard: () -> Unit, cardPosition: CardPosition?, onMoveUp: (() -> Unit)?, @@ -191,7 +191,12 @@ private fun LoadedContent( Column( modifier = Modifier .fillMaxWidth() - .clickable { onPostClick(state.postId) } + .clickable { + onPostClick( + state.postId, + state.postTitle + ) + } .padding(CardPadding) ) { StatsCardHeader( @@ -270,7 +275,7 @@ private fun LatestPostCardLoadedPreview() { ), onRemoveCard = {}, onRetry = {}, - onPostClick = {}, + onPostClick = { _, _ -> }, onCreatePostClick = {} ) } @@ -284,7 +289,7 @@ private fun LatestPostCardErrorPreview() { uiState = LatestPostCardUiState.Error, onRemoveCard = {}, onRetry = {}, - onPostClick = {}, + onPostClick = { _, _ -> }, onCreatePostClick = {} ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt index ce32d24e2be2..666bef47c516 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -51,12 +52,14 @@ import org.wordpress.android.ui.newstats.components.StatsListHeader import org.wordpress.android.ui.newstats.components.StatsLabeledValue import org.wordpress.android.ui.newstats.components.StatsViewChange import org.wordpress.android.ui.newstats.datasource.PostViewsChange +import org.wordpress.android.ui.newstats.datasource.PostViewsDailyView import org.wordpress.android.ui.newstats.datasource.PostViewsData import org.wordpress.android.ui.newstats.datasource.PostViewsWeek import org.wordpress.android.ui.newstats.util.ShimmerBox import org.wordpress.android.ui.newstats.util.formatChangePercentage import org.wordpress.android.ui.newstats.util.formatStatAverage -import org.wordpress.android.ui.newstats.util.formatStatCount +import org.wordpress.android.ui.newstats.util.TEN_THOUSAND +import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.ui.newstats.util.formatStatsDate import org.wordpress.android.ui.newstats.util.formatStatsDateTime @@ -72,7 +75,8 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val postId = intent.getLongExtra(ARG_POST_ID, 0L) + // 0 is a real id (the home page), so it can't stand in for a missing extra. + val postId = intent.getLongExtra(ARG_POST_ID, NO_POST_ID) val postTitle = intent .getStringExtra(ARG_POST_TITLE).orEmpty() // The view model survives recreation, so only the first creation kicks off the fetch -- @@ -101,6 +105,7 @@ class PostStatsDetailActivity : BaseAppCompatActivity() { companion object { private const val ARG_POST_ID = "post_id" + private const val NO_POST_ID = -1L private const val ARG_POST_TITLE = "post_title" /** @@ -244,15 +249,25 @@ private fun LoadedContent( item { PostHeader(data, fallbackTitle) } item { Spacer(modifier = Modifier.height(24.dp)) } - if (state.selectedDay != null) { + val selectedDay = state.selectedDay + if (selectedDay != null) { item { DaySelector( - state = state, + day = selectedDay, + hasPreviousDay = + state.previousDay != null, + hasNextDay = state.hasNextDay, onPreviousDay = onPreviousDay, onNextDay = onNextDay ) - SelectedDayViews(state) - DayViewsChart(state) + SelectedDayViews( + views = selectedDay.views, + previousViews = state.previousDay?.views + ) + DayViewsChart( + days = state.chartDays, + selectedIndex = state.selectedChartIndex + ) Spacer( modifier = Modifier.height(24.dp) ) @@ -270,7 +285,7 @@ private fun LoadedContent( ) { DetailRow( label = it.year, - value = formatStatCount(it.total) + value = formatStatValue(it.total, TEN_THOUSAND) ) } @@ -343,16 +358,19 @@ private fun PostHeader(data: PostViewsData, fallbackTitle: String) { ) { StatsLabeledValue( labelResId = R.string.stats_views, - value = data.totalViews + value = data.totalViews, + abbreviateFrom = TEN_THOUSAND ) if (post != null) { StatsLabeledValue( labelResId = R.string.stats_likes, - value = post.likeCount + value = post.likeCount, + abbreviateFrom = TEN_THOUSAND ) StatsLabeledValue( labelResId = R.string.stats_comments, - value = post.commentCount + value = post.commentCount, + abbreviateFrom = TEN_THOUSAND ) } } @@ -364,11 +382,12 @@ private fun PostHeader(data: PostViewsData, fallbackTitle: String) { */ @Composable private fun DaySelector( - state: PostStatsDetailUiState.Loaded, + day: PostViewsDailyView, + hasPreviousDay: Boolean, + hasNextDay: Boolean, onPreviousDay: () -> Unit, onNextDay: () -> Unit ) { - val day = state.selectedDay ?: return Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -382,7 +401,7 @@ private fun DaySelector( ) IconButton( onClick = onPreviousDay, - enabled = state.hasPreviousDay + enabled = hasPreviousDay ) { Icon( imageVector = Icons.AutoMirrored @@ -394,7 +413,7 @@ private fun DaySelector( } IconButton( onClick = onNextDay, - enabled = state.hasNextDay + enabled = hasNextDay ) { Icon( imageVector = Icons.AutoMirrored @@ -412,11 +431,9 @@ private fun DaySelector( */ @Composable private fun SelectedDayViews( - state: PostStatsDetailUiState.Loaded + views: Long, + previousViews: Long? ) { - val views = state.selectedDay?.views ?: return - val previous = state.previousDay?.views - Row( modifier = Modifier .fillMaxWidth() @@ -424,7 +441,7 @@ private fun SelectedDayViews( verticalAlignment = Alignment.Bottom ) { Text( - text = formatStatCount(views), + text = formatStatValue(views, TEN_THOUSAND), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface @@ -438,8 +455,11 @@ private fun SelectedDayViews( .padding(start = 8.dp, bottom = 2.dp) .weight(1f) ) - if (previous != null) { - DayChangeLabel(views = views, previous = previous) + if (previousViews != null) { + DayChangeLabel( + views = views, + previous = previousViews + ) } } } @@ -448,7 +468,7 @@ private fun SelectedDayViews( private fun DayChangeLabel(views: Long, previous: Long) { val difference = views - previous val percentage = when { - previous == views -> "0%" + previous == views -> formatChangePercentage(0.0) previous == 0L -> INFINITY else -> formatChangePercentage( difference.toDouble() / previous @@ -462,7 +482,7 @@ private fun DayChangeLabel(views: Long, previous: Long) { } else { R.string.stats_traffic_change }, - formatStatCount(difference), + formatStatValue(difference, TEN_THOUSAND), percentage ), style = MaterialTheme.typography.bodyMedium, @@ -476,19 +496,24 @@ private fun DayChangeLabel(views: Long, previous: Long) { @Composable private fun DayViewsChart( - state: PostStatsDetailUiState.Loaded + days: List, + selectedIndex: Int ) { - val days = state.chartDays - if (days.isEmpty()) return + // Derived once per window so the chart's remember() keys stay stable across recompositions, + // and so no date parsing happens on Vico's measure/draw path. + val values = remember(days) { days.map { it.views } } + val startLabel = remember(days) { + formatStatsDate(days.first().day) + } + val endLabel = remember(days) { + formatStatsDate(days.last().day) + } StatsDayViewsChart( - values = days.map { it.views }, - selectedIndex = days.lastIndex, + values = values, + selectedIndex = selectedIndex, height = ChartHeight, - bottomAxisLabel = { index -> - days.getOrNull(index)?.day - ?.let(::formatStatsDate) - ?: days.last().day.let(::formatStatsDate) - }, + startLabel = startLabel, + endLabel = endLabel, modifier = Modifier.padding(top = 8.dp) ) } @@ -508,7 +533,7 @@ private fun SectionTitle(titleResId: Int) { private fun WeekRow(week: PostViewsWeek) { DetailRow( label = weekLabel(week), - value = formatStatCount(week.total) + value = formatStatValue(week.total, TEN_THOUSAND) ) { when (val change = week.change) { is PostViewsChange.Infinite -> Text( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt index 74890ada40eb..6301fec5fdea 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt @@ -105,7 +105,7 @@ class PostStatsDetailViewModel @Inject constructor( return@update state } val target = state.selectedDayIndex + offset - if (target in state.data.dailyViews.indices) { + if (target in state.chartRange) { state.copy(selectedDayIndex = target) } else { state @@ -122,38 +122,42 @@ sealed class PostStatsDetailUiState { /** Index into [PostViewsData.dailyViews]; -1 when the post has no history. */ val selectedDayIndex: Int ) : PostStatsDetailUiState() { - val selectedDay: PostViewsDailyView? - get() = data.dailyViews - .getOrNull(selectedDayIndex) + /** + * The days the chart draws: a fixed trailing window, so stepping through days moves the + * highlight within a stable set of bars rather than sliding the whole chart. This is the + * framing the old stats screen uses, and it bounds how far back the arrows can go. + */ + val chartDays: List = + data.dailyViews.subList( + (data.dailyViews.size - CHART_DAYS) + .coerceAtLeast(0), + data.dailyViews.size + ) - /** The day before the selected one, which the change is measured against. */ - val previousDay: PostViewsDailyView? - get() = data.dailyViews - .getOrNull(selectedDayIndex - 1) + /** Indices of [PostViewsData.dailyViews] the chart covers, i.e. what the arrows can reach. */ + val chartRange: IntRange = + data.dailyViews.size - chartDays.size until + data.dailyViews.size - val hasPreviousDay: Boolean - get() = selectedDayIndex > 0 + /** Position of the selected day within [chartDays]. */ + val selectedChartIndex: Int = + selectedDayIndex - chartRange.first - val hasNextDay: Boolean - get() = selectedDayIndex < - data.dailyViews.lastIndex + val selectedDay: PostViewsDailyView? = + data.dailyViews.getOrNull(selectedDayIndex) - /** - * The window of days the chart draws, ending at the selected day so it is always the - * rightmost bar -- the same framing the old stats screen uses. - */ - val chartDays: List - get() = data.dailyViews - .take(selectedDayIndex + 1) - .takeLast(CHART_DAYS) + /** The day before the selected one, which the change is measured against. */ + val previousDay: PostViewsDailyView? = + data.dailyViews.getOrNull(selectedDayIndex - 1) + + val hasNextDay: Boolean = + selectedDayIndex < chartRange.last } data class Error( val message: String ) : PostStatsDetailUiState() - - companion object { - // Two weeks of bars, matching the old stats post detail chart. - const val CHART_DAYS = 14 - } } + +// Two weeks of bars, matching the old stats post detail chart. +private const val CHART_DAYS = 14 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index 14712e02c025..d954b0826649 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -10,7 +10,7 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Locale -private const val THOUSAND = 1_000 +const val THOUSAND = 1_000 private const val MILLION = 1_000_000 private const val FORMAT_MILLION = "%.1fM" private const val FORMAT_THOUSAND = "%.1fK" @@ -58,31 +58,36 @@ private inline fun parseOrLog( } /** - * Formats a stat value for display, using K/M suffixes for large numbers. - * Examples: 1500 -> "1.5K", 2500000 -> "2.5M", 500 -> "500" + * Formats a stat value for display, grouped in full below [abbreviateFrom] and with a K/M suffix + * at or above it. Examples at the default threshold: 500 -> "500", 1500 -> "1.5K", + * 2500000 -> "2.5M"; at [TEN_THOUSAND]: 1986 -> "1,986". + * + * Cards are width-constrained and abbreviate early; detail screens pass [TEN_THOUSAND] so figures + * stay exact for longer, which is the threshold the old stats screens use. */ -fun formatStatValue(value: Long): String { - return when { - value >= MILLION -> String.format(Locale.getDefault(), FORMAT_MILLION, value / MILLION.toDouble()) - value >= THOUSAND -> String.format(Locale.getDefault(), FORMAT_THOUSAND, value / THOUSAND.toDouble()) - else -> value.toString() - } -} - -private const val TEN_THOUSAND = 10_000 - -/** - * Formats a stat value for a detail list, where precision matters more than width: grouped in full - * up to 10,000 ("1,986"), abbreviated above it ("74.3K"). Matches the thresholds the old stats - * screens use; [formatStatValue] is the compact form for cards. - */ -fun formatStatCount(value: Long): String = if (value < TEN_THOUSAND) { - NumberFormat.getIntegerInstance(Locale.getDefault()) +fun formatStatValue( + value: Long, + abbreviateFrom: Int = THOUSAND +): String = when { + value >= MILLION -> String.format( + Locale.getDefault(), + FORMAT_MILLION, + value / MILLION.toDouble() + ) + value >= abbreviateFrom.coerceAtLeast(THOUSAND) -> + String.format( + Locale.getDefault(), + FORMAT_THOUSAND, + value / THOUSAND.toDouble() + ) + else -> NumberFormat + .getIntegerInstance(Locale.getDefault()) .format(value) -} else { - formatStatValue(value) } +/** Threshold for detail screens, which favour exact figures over width. */ +const val TEN_THOUSAND = 10_000 + /** * Formats a fractional change as a percentage, e.g. -0.25 -> "-25%". */ From c72c8114369fb6bf74f790504bf221cc831609ec Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 15:01:42 -0400 Subject: [PATCH 6/9] Show the featured image on the Latest Post Summary card The old card showed one and the new one didn't. The per-post stats response carries no image -- it has the post's ids, dates, slug and status but no media -- so the image comes from the post lookup instead: that call now asks for FEATURED_MEDIA alongside the id, and resolves the attachment id to a URL through the media endpoint. The media call is made only when the post has a featured image, and a failure there costs the thumbnail rather than the card, since the stats are what the card is for. --- .../datasource/LatestPostDataSource.kt | 90 ++++++++++++++----- .../ui/newstats/latestpost/LatestPostCard.kt | 63 +++++++++---- .../latestpost/LatestPostCardUiState.kt | 4 +- .../latestpost/LatestPostViewModel.kt | 4 +- .../repository/StatsLatestPostUseCase.kt | 10 ++- 5 files changed, 128 insertions(+), 43 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt index 9716246cfec1..af248744d51f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt @@ -4,11 +4,14 @@ import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T +import rs.wordpress.api.kotlin.WpApiClient import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.MediaId import uniffi.wp_api.PostEndpointType import uniffi.wp_api.PostListParams import uniffi.wp_api.PostStatus import uniffi.wp_api.SparseAnyPostFieldWithViewContext +import uniffi.wp_api.SparseMediaFieldWithViewContext import uniffi.wp_api.WpApiParamOrder import uniffi.wp_api.WpApiParamPostsOrderBy import javax.inject.Inject @@ -36,31 +39,37 @@ class LatestPostDataSource @Inject constructor( status = listOf(PostStatus.Publish) ) - // Only the ID is needed; without the field filter the response carries the post's whole - // rendered content, excerpt and taxonomy payload. - val result = wpApiClientProvider - .getWpApiClient(site) - .request { requestBuilder -> - requestBuilder.posts() - .filterListWithViewContext( - postEndpointType = - PostEndpointType.Posts, - params = params, - fields = listOf( - SparseAnyPostFieldWithViewContext - .ID - ) + // Only the id and featured image are needed; without the field filter the response + // carries the post's whole rendered content, excerpt and taxonomy payload. + val client = wpApiClientProvider.getWpApiClient(site) + val result = client.request { requestBuilder -> + requestBuilder.posts() + .filterListWithViewContext( + postEndpointType = + PostEndpointType.Posts, + params = params, + fields = listOf( + SparseAnyPostFieldWithViewContext.ID, + SparseAnyPostFieldWithViewContext + .FEATURED_MEDIA ) - } + ) + } return when (result) { - is WpRequestResult.Success -> - result.response.data - .firstOrNull()?.id - ?.let { - LatestPostLookupResult.Success(it) - } - ?: LatestPostLookupResult.NoPosts + is WpRequestResult.Success -> { + val post = result.response.data.firstOrNull() + val postId = post?.id + if (postId == null) { + LatestPostLookupResult.NoPosts + } else { + LatestPostLookupResult.Success( + postId = postId, + featuredImageUrl = post.featuredMedia + ?.let { fetchImageUrl(client, it) } + ) + } + } else -> { val message = ( result as? WpRequestResult.WpError<*> @@ -76,6 +85,39 @@ class LatestPostDataSource @Inject constructor( } } } + + /** + * Resolves a featured image's URL. The post carries only the attachment id, so this is a + * second call -- made only when there is an image. A failure here costs the thumbnail, not + * the card, so it degrades to null rather than propagating. + */ + private suspend fun fetchImageUrl( + client: WpApiClient, + mediaId: MediaId + ): String? { + val result = client.request { requestBuilder -> + requestBuilder.media() + .filterRetrieveWithViewContext( + mediaId = mediaId, + fields = listOf( + SparseMediaFieldWithViewContext + .SOURCE_URL + ) + ) + } + return when (result) { + is WpRequestResult.Success -> + result.response.data.sourceUrl + else -> { + AppLog.w( + T.STATS, + "LatestPostDataSource: could not " + + "resolve featured image $mediaId" + ) + null + } + } + } } /** @@ -84,7 +126,9 @@ class LatestPostDataSource @Inject constructor( */ sealed class LatestPostLookupResult { data class Success( - val postId: Long + val postId: Long, + /** Null when the post has no featured image, or when resolving its URL failed. */ + val featuredImageUrl: String? ) : LatestPostLookupResult() data object NoPosts : LatestPostLookupResult() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt index c45d5bb62bb2..2b571839e8a9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.newstats.latestpost import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,18 +9,22 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.newstats.components.CardPosition @@ -31,6 +36,9 @@ import org.wordpress.android.ui.newstats.components.StatsLabeledValue import org.wordpress.android.ui.newstats.util.ShimmerBox private val CardPadding = 16.dp +// Matches the featured image on the old Latest Post Summary card. +private val FeaturedImageSize = 68.dp +private val FeaturedImageCorner = 8.dp private val ChartHeight = 48.dp @Composable @@ -210,21 +218,43 @@ private fun LoadedContent( onMoveToBottom = onMoveToBottom ) Spacer(modifier = Modifier.height(12.dp)) - Text( - text = state.postTitle, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = state.postDate, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme - .onSurfaceVariant - ) + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.postTitle, + style = MaterialTheme.typography + .titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme + .onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = state.postDate, + style = MaterialTheme.typography + .bodySmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } + if (state.featuredImageUrl != null) { + Spacer(modifier = Modifier.width(12.dp)) + AsyncImage( + model = state.featuredImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(FeaturedImageSize) + .clip( + RoundedCornerShape( + FeaturedImageCorner + ) + ) + ) + } + } Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { StatsLabeledValue( @@ -271,7 +301,8 @@ private fun LatestPostCardLoadedPreview() { comments = 7L, recentViews = listOf( 12L, 40L, 33L, 80L, 65L, 21L, 54L - ) + ), + featuredImageUrl = null ), onRemoveCard = {}, onRetry = {}, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt index cb7f72621885..7a5a2c4aad05 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt @@ -14,7 +14,9 @@ sealed class LatestPostCardUiState { val likes: Long, val comments: Long, /** Daily views for the trailing week, oldest first. */ - val recentViews: List + val recentViews: List, + /** Null when the post has no featured image. */ + val featuredImageUrl: String? ) : LatestPostCardUiState() /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt index c51c0324b0e3..7129f24c900e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt @@ -117,7 +117,9 @@ class LatestPostViewModel @Inject constructor( comments = post.commentCount, recentViews = views.dailyViews .takeLast(CARD_CHART_DAYS) - .map { it.views } + .map { it.views }, + featuredImageUrl = + result.featuredImageUrl ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt index 2748241818ba..7dac2969bcec 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt @@ -45,7 +45,11 @@ class StatsLatestPostUseCase @Inject constructor( ) ) { is PostViewsResult.Success -> - LatestPostResult.Success(views.data) + LatestPostResult.Success( + data = views.data, + featuredImageUrl = + lookup.featuredImageUrl + ) is PostViewsResult.Error -> LatestPostResult.Error(views.message) } @@ -58,7 +62,9 @@ class StatsLatestPostUseCase @Inject constructor( */ sealed class LatestPostResult { data class Success( - val data: PostViewsData + val data: PostViewsData, + /** Null when the post has no featured image. */ + val featuredImageUrl: String? ) : LatestPostResult() data object NoPosts : LatestPostResult() From c66a94cb80acf37be25ad84ef7b1478fc39d61ca Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 15:07:23 -0400 Subject: [PATCH 7/9] Build against the wordpress-rs PR instead of a local checkout The per-post stats endpoint is still unreleased, so the branch was consuming wordpress-rs from a local composite build. That only works on a machine with the checkout, which is why every CI job failed to compile. Pins the artifact CI published for wordpress-rs#1489 instead. The commit is the PR's head, so the API matches what this branch was developed against. Still needs a final bump to a released version once that PR lands. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8bca16d1d798..9c04fac2b320 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '0.6.0' +wordpress-rs = '1489-ae229223a02c28bcb0b729b680201000634d8158' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 0289d191121c4b22f4b33155e88ff6a9196f7171 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 15:21:52 -0400 Subject: [PATCH 8/9] Drop the local wordpress-rs composite build hooks With the version pinned to the artifact CI publishes for the rs PR, nothing needs the includeBuild path any more. Restores both files to trunk, leaving the branch's only build change as the one-line version pin. --- config/gradle/included_builds.gradle | 10 ---------- local-builds.gradle-example | 1 - 2 files changed, 11 deletions(-) diff --git a/config/gradle/included_builds.gradle b/config/gradle/included_builds.gradle index 8f1b260c0d98..07698de07ef2 100644 --- a/config/gradle/included_builds.gradle +++ b/config/gradle/included_builds.gradle @@ -11,7 +11,6 @@ gradle.ext.aztecAndroidPicassoLoaderPath = "org.wordpress.aztec:picasso-loader" gradle.ext.aboutAutomatticBinaryPath = "com.automattic:about" gradle.ext.gravatarBinaryPath = "com.gravatar:gravatar" gradle.ext.gravatarQuickEditorBinaryPath = "com.gravatar:gravatar-quickeditor" -gradle.ext.wordpressRsBinaryPath = "rs.wordpress.api:android" def localBuilds = new File("${rootDir}/local-builds.gradle") if (localBuilds.exists()) { @@ -87,13 +86,4 @@ if (localBuilds.exists()) { } } } - - if (ext.has("localWordPressRsPath")) { - includeBuild("$ext.localWordPressRsPath/native/kotlin") { - dependencySubstitution { - println "Substituting wordpress-rs with the local build" - substitute module("$gradle.ext.wordpressRsBinaryPath") using project(':api:android') - } - } - } } diff --git a/local-builds.gradle-example b/local-builds.gradle-example index 72679cf82cd6..1b497c91eeab 100644 --- a/local-builds.gradle-example +++ b/local-builds.gradle-example @@ -17,5 +17,4 @@ ext { //localGutenbergKitPath = "../GutenbergKit" //localAztecAndroidPath = "../AztecEditor-Android" //localGravatarAndroidPath = "../Gravatar-SDK-Android" - //localWordPressRsPath = "../wordpress-rs" } From 55d2f5460caaad81bc6325d56647e942c2819a15 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 6 Aug 2026 15:22:04 -0400 Subject: [PATCH 9/9] Remove a duplicate formatter and the now-unused postType formatStatAverage did what the existing formatStatValue(Double) overload already does, and which Year In Review's detail screen already uses for its averages. Averages of a thousand or more lose their grouping separator as a result, which is how every other Double stat in the package renders. postType lost its only reader when the Posts & Pages rows moved to the Compose screen, which needs just an id and a title. It was still carried from the data source through the repository into two UI states, one of them Parcelable. --- .../android/ui/newstats/datasource/StatsDataSource.kt | 3 +-- .../ui/newstats/datasource/StatsDataSourceImpl.kt | 3 +-- .../ui/newstats/mostviewed/MostViewedCardUiState.kt | 7 ++----- .../ui/newstats/mostviewed/MostViewedViewModel.kt | 6 ++---- .../ui/newstats/poststats/PostStatsDetailActivity.kt | 3 +-- .../android/ui/newstats/repository/StatsRepository.kt | 6 ++---- .../wordpress/android/ui/newstats/util/StatsFormatter.kt | 9 --------- .../ui/newstats/mostviewed/MostViewedViewModelTest.kt | 8 ++------ 8 files changed, 11 insertions(+), 34 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index 5a488dec71ed..8e5504aa985b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -434,8 +434,7 @@ data class TopPostDataItem( val id: Long, val title: String, val views: Long, - val url: String? = null, - val postType: String? = null + val url: String? = null ) /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 4583fc8fe1ff..4bba9ce723ac 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -209,8 +209,7 @@ class StatsDataSourceImpl @Inject constructor( id = post.id.toLong(), title = post.title.orEmpty(), views = post.views?.toLong() ?: 0L, - url = post.href, - postType = post.postType + url = post.href ) } ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt index c5005be305b7..a6487f99fcdf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt @@ -40,7 +40,6 @@ sealed class MostViewedCardUiState { * @param change The percentage change compared to previous period * @param url The item's URL. Posts open it as detail stats; referrers and clicks open it in a * Custom Tab. Null when the item has no link of its own. - * @param postType The API post type, e.g. "post"/"page" (posts only) */ data class MostViewedItem( val id: Long, @@ -48,8 +47,7 @@ data class MostViewedItem( val views: Long, val change: MostViewedChange, val children: List = emptyList(), - val url: String? = null, - val postType: String? = null + val url: String? = null ) /** @@ -107,6 +105,5 @@ data class MostViewedDetailItem( val views: Long, val change: MostViewedChange, val children: List = emptyList(), - val url: String? = null, - val postType: String? = null + val url: String? = null ) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt index e66288db8ed9..b0d16f7940c9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt @@ -228,8 +228,7 @@ class MostViewedViewModel @Inject constructor( views = item.views, change = item.change, children = item.children, - url = item.url, - postType = item.postType + url = item.url ) }, maxViewsForBar = cardItems.firstOrNull()?.views ?: 1L @@ -341,7 +340,6 @@ internal fun MostViewedItemData.toDetailItem(): MostViewedDetailItem { children = children.map { child -> MostViewedChildItem(name = child.name, url = child.url, views = child.views) }, - url = url, - postType = postType + url = url ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt index 666bef47c516..9c7e0e7e4708 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt @@ -57,7 +57,6 @@ import org.wordpress.android.ui.newstats.datasource.PostViewsData import org.wordpress.android.ui.newstats.datasource.PostViewsWeek import org.wordpress.android.ui.newstats.util.ShimmerBox import org.wordpress.android.ui.newstats.util.formatChangePercentage -import org.wordpress.android.ui.newstats.util.formatStatAverage import org.wordpress.android.ui.newstats.util.TEN_THOUSAND import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.ui.newstats.util.formatStatsDate @@ -295,7 +294,7 @@ private fun LoadedContent( ) { DetailRow( label = it.year, - value = formatStatAverage(it.overall) + value = formatStatValue(it.overall) ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 4e5d75aa1fb3..d3b0f037ba8a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -956,8 +956,7 @@ class StatsRepository @Inject constructor( views = item.views, previousViews = previousViews, isFirst = index == 0, - url = item.url, - postType = item.postType + url = item.url ) }, totalViews = totalViews, @@ -2272,8 +2271,7 @@ data class MostViewedItemData( val previousViews: Long, val isFirst: Boolean, val children: List = emptyList(), - val url: String? = null, - val postType: String? = null + val url: String? = null ) { val viewsChange: Long get() = views - previousViews val viewsChangePercent: Double diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index d954b0826649..8f76c9caa237 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -96,15 +96,6 @@ fun formatChangePercentage(fraction: Double): String = .apply { maximumFractionDigits = 0 } .format(fraction) -/** - * Formats an average for a detail list, keeping one decimal place when there is one. - */ -fun formatStatAverage(value: Double): String = - NumberFormat.getInstance(Locale.getDefault()).apply { - maximumFractionDigits = 1 - minimumFractionDigits = 0 - }.format(value) - private const val FORMAT_DECIMAL = "%.1f" fun formatStatValue(value: Double): String { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt index 506115726ba6..a3a820ea5f04 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt @@ -191,7 +191,7 @@ class MostViewedViewModelTest : BaseUnitTest() { } @Test - fun `when data loads, then url and post type are passed to card and detail items`() = test { + fun `when data loads, then url is passed to card and detail items`() = test { whenever(statsRepository.fetchMostViewed(any(), any(), any())) .thenReturn(createSuccessResult()) whenever(resourceProvider.getString(R.string.stats_period_last_7_days)) @@ -202,11 +202,9 @@ class MostViewedViewModelTest : BaseUnitTest() { val state = viewModel.postsUiState.value as MostViewedCardUiState.Loaded assertThat(state.items[0].url).isEqualTo(TEST_POST_URL_1) - assertThat(state.items[0].postType).isEqualTo(TEST_POST_TYPE_1) val detailItems = viewModel.getPostsDetailData().items assertThat(detailItems[0].url).isEqualTo(TEST_POST_URL_1) - assertThat(detailItems[0].postType).isEqualTo(TEST_POST_TYPE_1) } @Test @@ -681,8 +679,7 @@ class MostViewedViewModelTest : BaseUnitTest() { views = TEST_POST_VIEWS_1, previousViews = TEST_POST_PREVIOUS_VIEWS_1, isFirst = true, - url = TEST_POST_URL_1, - postType = TEST_POST_TYPE_1 + url = TEST_POST_URL_1 ), MostViewedItemData( id = 2, @@ -707,7 +704,6 @@ class MostViewedViewModelTest : BaseUnitTest() { private const val TEST_POST_TITLE_1 = "Test Post 1" private const val TEST_POST_TITLE_2 = "Test Post 2" private const val TEST_POST_URL_1 = "https://example.com/test-post-1" - private const val TEST_POST_TYPE_1 = "post" private const val TEST_POST_VIEWS_1 = 500L private const val TEST_POST_VIEWS_2 = 300L private const val TEST_REFERRER_URL = "https://google.com/"