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" />
+
+
postType
- else -> StatsConstants.ITEM_TYPE_HOME_PAGE
-}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt
index 08f9a7f24a44..8721697d6d52 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt
@@ -12,6 +12,9 @@ enum class InsightsCardType(
ALL_TIME_STATS(
R.string.stats_insights_all_time_stats_title
),
+ LATEST_POST(
+ R.string.stats_insights_latest_post_summary
+ ),
MOST_POPULAR_DAY(
R.string.stats_insights_most_popular_day
),
@@ -27,6 +30,7 @@ enum class InsightsCardType(
listOf(
YEAR_IN_REVIEW,
ALL_TIME_STATS,
+ LATEST_POST,
MOST_POPULAR_DAY,
MOST_POPULAR_TIME,
TAGS_AND_CATEGORIES
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt
index 2185da821754..b1474fbfe244 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt
@@ -349,10 +349,11 @@ class InsightsViewModel @Inject constructor(
}
companion object {
- // TAGS_AND_CATEGORIES is intentionally absent
- // from both checks: it has its own dedicated
- // fetch path via StatsTagsUseCase in
- // TagsAndCategoriesViewModel.
+ // TAGS_AND_CATEGORIES and LATEST_POST are
+ // intentionally absent from both checks: each
+ // fetches on its own, from
+ // TagsAndCategoriesViewModel and
+ // LatestPostViewModel respectively.
private fun List.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..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
@@ -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
)
}
}
@@ -205,7 +210,21 @@ 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() {
+ selectedSiteRepository.getSelectedSite()?.let { site ->
+ ActivityLauncher.addNewPostForResult(
+ this,
+ site,
+ false,
+ PagePostCreationSourcesDetail.POST_FROM_STATS,
+ -1,
+ null
+ )
+ }
}
/**
@@ -306,7 +325,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 +468,8 @@ private fun NewStatsScreen(
tab = tabs[page],
viewsStatsViewModel = viewsStatsViewModel,
onStatsUrlClick = onStatsUrlClick,
- onPostItemClick = onPostItemClick
+ onPostItemClick = onPostItemClick,
+ onCreatePostClick = onCreatePostClick
)
}
}
@@ -460,7 +481,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 +491,8 @@ private fun StatsTabContent(
onPostItemClick = onPostItemClick
)
StatsTab.INSIGHTS -> InsightsTabContent(
- onStatsUrlClick = onStatsUrlClick
+ onStatsUrlClick = onStatsUrlClick,
+ onCreatePostClick = onCreatePostClick
)
StatsTab.SUBSCRIBERS -> SubscribersTabContent()
}
@@ -1121,8 +1144,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 +1155,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 +1171,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 +1247,11 @@ private fun InsightsTabContent(
) {
tagsAndCategoriesViewModel.refresh()
}
+ if (InsightsCardType.LATEST_POST
+ in visibleCards
+ ) {
+ latestPostViewModel.refresh()
+ }
},
indicator = {
PullToRefreshDefaults.Indicator(
@@ -1269,6 +1303,24 @@ 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, title ->
+ PostStatsDetailActivity.start(
+ context,
+ postId,
+ title
+ )
+ },
+ 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/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
new file mode 100644
index 000000000000..19a9a375e8d2
--- /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,
+ barSpacing: Dp,
+ modifier: Modifier = Modifier
+) {
+ 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/StatsDayViewsChart.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt
new file mode 100644
index 000000000000..ca57c4b156fc
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt
@@ -0,0 +1,124 @@
+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.
+ *
+ * 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,
+ startLabel: String,
+ endLabel: String,
+ modifier: Modifier = Modifier
+) {
+ 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 barColor = MaterialTheme.colorScheme.primary
+ 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) }
+ )
+ }
+
+ CartesianChartHost(
+ chart = rememberCartesianChart(
+ rememberColumnCartesianLayer(
+ columnProvider = columnProvider,
+ 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/components/StatsLabeledValue.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt
new file mode 100644
index 000000000000..3e30f2bba4e7
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt
@@ -0,0 +1,46 @@
+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.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,
+ abbreviateFrom: Int = THOUSAND
+) {
+ 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, abbreviateFrom),
+ 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..af248744d51f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt
@@ -0,0 +1,139 @@
+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.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
+
+/**
+ * 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 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 -> {
+ 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<*>
+ )?.errorMessage
+ ?: "Failed to fetch the latest post"
+ AppLog.e(
+ T.STATS,
+ "LatestPostDataSource: " +
+ "fetchLatestPublishedPost " +
+ "failed - $message"
+ )
+ LatestPostLookupResult.Error(message)
+ }
+ }
+ }
+
+ /**
+ * 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
+ }
+ }
+ }
+}
+
+/**
+ * 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,
+ /** Null when the post has no featured image, or when resolving its URL failed. */
+ val featuredImageUrl: String?
+ ) : 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..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
@@ -245,6 +245,18 @@ 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; 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
+ ): PostViewsDataResult
+
/**
* Fetches subscriber count stats for a specific site.
*
@@ -422,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
)
/**
@@ -764,6 +775,103 @@ 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.
+ *
+ * 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 totalViews: Long,
+ /** 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. */
+ val years: List,
+ /** Yearly averages, most recent year first. */
+ val averages: List,
+ /** The post these stats belong to, or null for the site's home page. */
+ 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.
+ */
+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
+)
+
+/**
+ * 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..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
@@ -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
@@ -206,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
)
}
)
@@ -1243,6 +1245,107 @@ class StatsDataSourceImpl @Inject constructor(
}
}
+ override suspend fun fetchPostViews(
+ siteId: Long,
+ postId: Long
+ ): PostViewsDataResult {
+ // The endpoint takes no query params -- num, date and period are silently ignored.
+ 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,
+ postId
+ )
+ )
+ }
+ else -> logErrorAndReturn(
+ "fetchPostViews",
+ result
+ ) {
+ PostViewsDataResult.Error(it)
+ }
+ }
+ }
+
+ private fun mapToPostViewsData(
+ response: StatsPostResponse,
+ 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 = 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
+ .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 },
+ // 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() =
+ 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..2b571839e8a9
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt
@@ -0,0 +1,327 @@
+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
+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
+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
+// 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
+@Suppress("LongParameterList")
+fun LatestPostCard(
+ uiState: LatestPostCardUiState,
+ onRemoveCard: () -> Unit,
+ onRetry: () -> Unit,
+ onPostClick: (postId: Long, title: String) -> 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: (postId: Long, title: String) -> Unit,
+ onRemoveCard: () -> Unit,
+ cardPosition: CardPosition?,
+ onMoveUp: (() -> Unit)?,
+ onMoveToTop: (() -> Unit)?,
+ onMoveDown: (() -> Unit)?,
+ onMoveToBottom: (() -> Unit)?
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onPostClick(
+ state.postId,
+ state.postTitle
+ )
+ }
+ .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))
+ 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(
+ 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
+ ),
+ featuredImageUrl = null
+ ),
+ 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..7a5a2c4aad05
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt
@@ -0,0 +1,27 @@
+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,
+ /** Null when the post has no featured image. */
+ val featuredImageUrl: String?
+ ) : 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..7129f24c900e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt
@@ -0,0 +1,137 @@
+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
+ 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.dailyViews
+ .takeLast(CARD_CHART_DAYS)
+ .map { it.views },
+ featuredImageUrl =
+ result.featuredImageUrl
+ )
+ }
+ }
+ is LatestPostResult.NoPosts ->
+ LatestPostCardUiState.NoData
+ is LatestPostResult.Error ->
+ 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/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/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/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
new file mode 100644
index 000000000000..9c7e0e7e4708
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt
@@ -0,0 +1,596 @@
+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.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
+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.runtime.remember
+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.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.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.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
+
+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)
+
+ // 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 --
+ // 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,
+ postTitle = postTitle,
+ onBackPressed =
+ onBackPressedDispatcher
+ ::onBackPressed,
+ onRetry = { viewModel.loadData(postId) },
+ onPreviousDay = viewModel::selectPreviousDay,
+ onNextDay = viewModel::selectNextDay
+ )
+ }
+ }
+ }
+
+ 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"
+
+ /**
+ * @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_TITLE, postTitle)
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+@Suppress("LongParameterList")
+private fun PostStatsDetailScreen(
+ uiState: PostStatsDetailUiState,
+ postTitle: String,
+ onBackPressed: () -> Unit,
+ onRetry: () -> Unit,
+ onPreviousDay: () -> Unit,
+ onNextDay: () -> 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(
+ state = uiState,
+ fallbackTitle = postTitle,
+ onPreviousDay = onPreviousDay,
+ onNextDay = onNextDay
+ )
+ }
+ }
+ }
+}
+
+@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(
+ state: PostStatsDetailUiState.Loaded,
+ fallbackTitle: String,
+ onPreviousDay: () -> Unit,
+ onNextDay: () -> Unit
+) {
+ val data = state.data
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(ScreenPadding)
+ ) {
+ item { PostHeader(data, fallbackTitle) }
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+
+ val selectedDay = state.selectedDay
+ if (selectedDay != null) {
+ item {
+ DaySelector(
+ day = selectedDay,
+ hasPreviousDay =
+ state.previousDay != null,
+ hasNextDay = state.hasNextDay,
+ onPreviousDay = onPreviousDay,
+ onNextDay = onNextDay
+ )
+ SelectedDayViews(
+ views = selectedDay.views,
+ previousViews = state.previousDay?.views
+ )
+ DayViewsChart(
+ days = state.chartDays,
+ selectedIndex = state.selectedChartIndex
+ )
+ 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, TEN_THOUSAND)
+ )
+ }
+
+ 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)
+ 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)) }
+}
+
+/**
+ * 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, fallbackTitle: String) {
+ val post = data.post
+ Column {
+ Text(
+ text = post?.title?.takeIf { it.isNotBlank() }
+ ?: fallbackTitle,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ 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(),
+ horizontalArrangement =
+ Arrangement.spacedBy(24.dp)
+ ) {
+ StatsLabeledValue(
+ labelResId = R.string.stats_views,
+ value = data.totalViews,
+ abbreviateFrom = TEN_THOUSAND
+ )
+ if (post != null) {
+ StatsLabeledValue(
+ labelResId = R.string.stats_likes,
+ value = post.likeCount,
+ abbreviateFrom = TEN_THOUSAND
+ )
+ StatsLabeledValue(
+ labelResId = R.string.stats_comments,
+ value = post.commentCount,
+ abbreviateFrom = TEN_THOUSAND
+ )
+ }
+ }
+ }
+}
+
+/**
+ * The selected day with arrows to step through the history, mirroring the old stats date bar.
+ */
+@Composable
+private fun DaySelector(
+ day: PostViewsDailyView,
+ hasPreviousDay: Boolean,
+ hasNextDay: Boolean,
+ onPreviousDay: () -> Unit,
+ onNextDay: () -> Unit
+) {
+ 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 = hasPreviousDay
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored
+ .Filled.KeyboardArrowLeft,
+ contentDescription = stringResource(
+ R.string.stats_previous_day
+ )
+ )
+ }
+ IconButton(
+ onClick = onNextDay,
+ enabled = 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(
+ views: Long,
+ previousViews: Long?
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Text(
+ text = formatStatValue(views, TEN_THOUSAND),
+ 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 (previousViews != null) {
+ DayChangeLabel(
+ views = views,
+ previous = previousViews
+ )
+ }
+ }
+}
+
+@Composable
+private fun DayChangeLabel(views: Long, previous: Long) {
+ val difference = views - previous
+ val percentage = when {
+ previous == views -> formatChangePercentage(0.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
+ },
+ formatStatValue(difference, TEN_THOUSAND),
+ percentage
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (positive) {
+ StatsColors.ChangeBadgePositive
+ } else {
+ StatsColors.ChangeBadgeNegative
+ }
+ )
+}
+
+@Composable
+private fun DayViewsChart(
+ days: List,
+ selectedIndex: Int
+) {
+ // 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 = values,
+ selectedIndex = selectedIndex,
+ height = ChartHeight,
+ startLabel = startLabel,
+ endLabel = endLabel,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+}
+
+@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, TEN_THOUSAND)
+ ) {
+ 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..6301fec5fdea
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt
@@ -0,0 +1,163 @@
+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.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
+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
+ )
+ _uiState.value = when (result) {
+ is PostViewsResult.Success ->
+ PostStatsDetailUiState.Loaded(
+ 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(
+ 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
+ )
+ )
+ }
+ }
+ }
+
+ /** 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.chartRange) {
+ state.copy(selectedDayIndex = target)
+ } else {
+ state
+ }
+ }
+ }
+}
+
+sealed class PostStatsDetailUiState {
+ data object Loading : PostStatsDetailUiState()
+
+ data class Loaded(
+ val data: PostViewsData,
+ /** Index into [PostViewsData.dailyViews]; -1 when the post has no history. */
+ val selectedDayIndex: Int
+ ) : PostStatsDetailUiState() {
+ /**
+ * 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
+ )
+
+ /** 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
+
+ /** Position of the selected day within [chartDays]. */
+ val selectedChartIndex: Int =
+ selectedDayIndex - chartRange.first
+
+ val selectedDay: PostViewsDailyView? =
+ data.dailyViews.getOrNull(selectedDayIndex)
+
+ /** 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()
+}
+
+// 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/repository/StatsLatestPostUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt
new file mode 100644
index 000000000000..7dac2969bcec
--- /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
+ )
+ ) {
+ is PostViewsResult.Success ->
+ LatestPostResult.Success(
+ data = views.data,
+ featuredImageUrl =
+ lookup.featuredImageUrl
+ )
+ is PostViewsResult.Error ->
+ LatestPostResult.Error(views.message)
+ }
+ }
+ }
+}
+
+/**
+ * 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,
+ /** Null when the post has no featured image. */
+ val featuredImageUrl: String?
+ ) : 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..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
@@ -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
@@ -954,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,
@@ -1847,6 +1848,35 @@ class StatsRepository @Inject constructor(
}
}
+ /**
+ * 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
+ ): PostViewsResult = withContext(ioDispatcher) {
+ val result = statsDataSource.fetchPostViews(
+ siteId = siteId,
+ postId = postId
+ )
+ 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.
@@ -2241,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
@@ -2543,6 +2572,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..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
@@ -2,27 +2,100 @@ 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.text.NumberFormat
+import java.time.LocalDate
+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"
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 a stat value for display, using K/M suffixes for large numbers.
- * Examples: 1500 -> "1.5K", 2500000 -> "2.5M", 500 -> "500"
+ * 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 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()
- }
+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, 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,
+ 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)
+}
+
+/** 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%".
+ */
+fun formatChangePercentage(fraction: Double): String =
+ NumberFormat.getPercentInstance(Locale.getDefault())
+ .apply { maximumFractionDigits = 0 }
+ .format(fraction)
+
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 945176bfb3cd..a7130bf44bdd 100644
--- a/WordPress/src/main/res/values/strings.xml
+++ b/WordPress/src/main/res/values/strings.xml
@@ -1629,6 +1629,9 @@
Avg. Views Per Day
Recent Weeks
Top Commentators
+ Post Stats
+ Previous day
+ Next day
Posts and Pages
Referrers
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)
- }
-}
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/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/"
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/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'