diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 305d1d3deb4c..2ce7833dc6dc 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 5ffeb9979ebb..68ba306fb9be 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 @@ -73,6 +73,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 @@ -114,8 +115,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 @@ -213,7 +217,9 @@ class NewStatsActivity : BaseAppCompatActivity() { onStatsUrlClick = { url -> activityNavigator.openInCustomTab(this, url) }, - onPostItemClick = ::openPostDetailStats + onPostItemClick = ::openPostDetailStats, + onLatestPostClick = ::openLatestPostStats, + onCreatePostClick = ::createNewPost ) } } @@ -243,7 +249,28 @@ 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 openLatestPostStats(postId: Long, title: String) { + analyticsTracker.track( + Stat.STATS_LATEST_POST_SUMMARY_VIEW_POST_DETAILS_TAPPED + ) + PostStatsDetailActivity.start(this, postId, title) + } + + private fun createNewPost() { + selectedSiteRepository.getSelectedSite()?.let { site -> + ActivityLauncher.addNewPostForResult( + this, + site, + false, + PagePostCreationSourcesDetail.POST_FROM_STATS, + -1, + null + ) + } } /** @@ -365,7 +392,9 @@ private fun NewStatsScreen( showIntroBottomSheet: Boolean = false, onIntroDismissed: () -> Unit = {}, onStatsUrlClick: (String) -> Unit = {}, - onPostItemClick: (MostViewedItem) -> Unit = {} + onPostItemClick: (MostViewedItem) -> Unit = {}, + onLatestPostClick: (Long, String) -> Unit = { _, _ -> }, + onCreatePostClick: () -> Unit = {} ) { val viewsStatsViewModel: ViewsStatsViewModel = viewModel() val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState() @@ -547,7 +576,9 @@ private fun NewStatsScreen( tab = tabs[page], viewsStatsViewModel = viewsStatsViewModel, onStatsUrlClick = onStatsUrlClick, - onPostItemClick = onPostItemClick + onPostItemClick = onPostItemClick, + onLatestPostClick = onLatestPostClick, + onCreatePostClick = onCreatePostClick ) } } @@ -559,7 +590,9 @@ private fun StatsTabContent( tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel, onStatsUrlClick: (String) -> Unit = {}, - onPostItemClick: (MostViewedItem) -> Unit = {} + onPostItemClick: (MostViewedItem) -> Unit = {}, + onLatestPostClick: (Long, String) -> Unit = { _, _ -> }, + onCreatePostClick: () -> Unit = {} ) { when (tab) { StatsTab.TRAFFIC -> TrafficTabContent( @@ -568,7 +601,9 @@ private fun StatsTabContent( onPostItemClick = onPostItemClick ) StatsTab.INSIGHTS -> InsightsTabContent( - onStatsUrlClick = onStatsUrlClick + onStatsUrlClick = onStatsUrlClick, + onLatestPostClick = onLatestPostClick, + onCreatePostClick = onCreatePostClick ) StatsTab.SUBSCRIBERS -> SubscribersTabContent() } @@ -1221,8 +1256,11 @@ private fun InsightsTabContent( mostPopularDayViewModel: MostPopularDayViewModel = viewModel(), mostPopularTimeViewModel: MostPopularTimeViewModel = viewModel(), tagsAndCategoriesViewModel: TagsAndCategoriesViewModel = viewModel(), + latestPostViewModel: LatestPostViewModel = viewModel(), insightsViewModel: InsightsViewModel = viewModel(), - onStatsUrlClick: (String) -> Unit = {} + onStatsUrlClick: (String) -> Unit = {}, + onLatestPostClick: (Long, String) -> Unit = { _, _ -> }, + onCreatePostClick: () -> Unit = {} ) { val context = LocalContext.current val yearInReviewUiState by yearInReviewViewModel.uiState.collectAsState() @@ -1230,6 +1268,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() @@ -1245,6 +1284,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() } } @@ -1318,6 +1360,11 @@ private fun InsightsTabContent( ) { tagsAndCategoriesViewModel.refresh() } + if (InsightsCardType.LATEST_POST + in visibleCards + ) { + latestPostViewModel.refresh() + } }, indicator = { PullToRefreshDefaults.Indicator( @@ -1369,6 +1416,18 @@ 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 = onLatestPostClick, + 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 863402c19171..aee382bae795 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 @@ -10,6 +10,12 @@ 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) + // Per-metric chart accent colors, used when a metric is selected as the charted series so the // chart recolours per selection (matching iOS's SiteMetric.primaryColor). Views intentionally // has no color here: it falls back to the theme primary for visual continuity with the previous 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..16039d100cc2 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: Long +) + /** * 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 974f32d8dd00..e1ab44fec890 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,10 @@ 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.StatsPostTarget +import uniffi.wp_api.StatsPostWeek import uniffi.wp_api.StatsTagsParams import uniffi.wp_api.StatsSearchTermsParams import uniffi.wp_api.StatsSearchTermsPeriod @@ -206,8 +210,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 +1246,113 @@ 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. + // The API addresses the home page as post 0, which the target models explicitly. + val target = if (postId == HOME_PAGE_POST_ID) { + StatsPostTarget.HomePage + } else { + StatsPostTarget.Post(postId) + } + val result = getOrCreateClient() + .request { requestBuilder -> + requestBuilder.statsPost() + .getStatsPost( + wpComSiteId = siteId.toULong(), + statsPostTarget = target + ) + } + + 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.toLong() + ) + } + .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, @@ -1483,6 +1593,8 @@ class StatsDataSourceImpl @Inject constructor( } companion object { + // The API addresses the site's home page as post 0. + private const val HOME_PAGE_POST_ID = 0L private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_FORBIDDEN = 403 } 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..5cc614d3bec9 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt @@ -0,0 +1,594 @@ +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) + + 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 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, TEN_THOUSAND) + ) + } + } +} + +/** + * 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..0f6bf4d7e3c0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt @@ -0,0 +1,171 @@ +package org.wordpress.android.ui.newstats.poststats + +import androidx.annotation.StringRes +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) { + if (postId == NO_POST_ID) { + AppLog.w( + AppLog.T.STATS, + "Post stats opened without a post id" + ) + showError(R.string.stats_error_unknown) + return + } + val site = selectedSiteRepository.getSelectedSite() + val token = accountStore.accessToken + if (site == null || token.isNullOrEmpty()) { + showError(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 + ) + when (result) { + is PostViewsResult.Success -> + _uiState.value = + PostStatsDetailUiState.Loaded( + data = result.data, + // The newest day is selected first, as the old screen does. + selectedDayIndex = result.data + .dailyViews.lastIndex + ) + is PostViewsResult.Error -> + showError(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 + ) + showError(R.string.stats_error_unknown) + } + } + } + + private fun showError(@StringRes messageResId: Int) { + _uiState.value = PostStatsDetailUiState.Error( + resourceProvider.getString(messageResId) + ) + } + + /** 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 + +/** + * Stands in for a missing post id. Post 0 is the site's home page, so it can't double as "absent". + */ +internal const val NO_POST_ID = -1L 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 6fe602a5fb70..3872652ccbc6 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 @@ -1047,8 +1049,7 @@ class StatsRepository @Inject constructor( views = item.views, previousViews = previousViews, isFirst = index == 0, - url = item.url, - postType = item.postType + url = item.url ) }, totalViews = totalViews, @@ -1940,6 +1941,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. @@ -2343,8 +2373,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 @@ -2645,6 +2674,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 07a2a360d248..6917e1590c7b 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,28 +2,99 @@ 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 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 724f1f4272a3..94904ac2a542 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1633,6 +1633,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/poststats/PostStatsDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModelTest.kt new file mode 100644 index 000000000000..66ca26bd220e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModelTest.kt @@ -0,0 +1,270 @@ +package org.wordpress.android.ui.newstats.poststats + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.lenient +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.PostViewsDailyView +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.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class PostStatsDetailViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: PostStatsDetailViewModel + + private val site = SiteModel().apply { + siteId = TEST_SITE_ID + } + + @Before + fun setUp() { + lenient().`when`(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + lenient().`when`( + selectedSiteRepository.getSelectedSite() + ).thenReturn(site) + lenient().`when`( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(ERROR) + lenient().`when`( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn(ERROR) + lenient().`when`( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn(ERROR) + viewModel = PostStatsDetailViewModel( + selectedSiteRepository, + statsRepository, + accountStore, + resourceProvider + ) + } + + @Test + fun `when there is no post id, then nothing is fetched`() = + test { + viewModel.loadData(NO_POST_ID) + + assertThat(viewModel.uiState.value) + .isInstanceOf( + PostStatsDetailUiState.Error::class.java + ) + verify(statsRepository, never()) + .fetchPostViews(any(), any()) + } + + @Test + fun `when no site is selected, then the state is an error`() = + test { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + viewModel.loadData(TEST_POST_ID) + + assertThat(viewModel.uiState.value) + .isInstanceOf( + PostStatsDetailUiState.Error::class.java + ) + } + + @Test + fun `when the fetch fails, then the state is an error`() = + test { + givenFetchReturns( + PostViewsResult.Error("boom") + ) + + viewModel.loadData(TEST_POST_ID) + + assertThat(viewModel.uiState.value) + .isInstanceOf( + PostStatsDetailUiState.Error::class.java + ) + } + + @Test + fun `when loaded, then the newest day is selected`() = + test { + givenLoaded(days = 30) + + val state = loadedState() + + assertThat(state.selectedDay?.day) + .isEqualTo(dayLabel(29)) + assertThat(state.hasNextDay).isFalse() + } + + @Test + fun `when history is longer than the window, then the chart is capped`() = + test { + givenLoaded(days = 30) + + assertThat(loadedState().chartDays) + .hasSize(CHART_DAYS) + } + + @Test + fun `when history is shorter than the window, then all of it charts`() = + test { + givenLoaded(days = 4) + + val state = loadedState() + + assertThat(state.chartDays).hasSize(4) + assertThat(state.selectedChartIndex) + .isEqualTo(3) + } + + @Test + fun `when stepping back, then the window holds and the highlight moves`() = + test { + givenLoaded(days = 30) + val before = loadedState().chartDays + + viewModel.selectPreviousDay() + viewModel.selectPreviousDay() + + val state = loadedState() + assertThat(state.chartDays).isEqualTo(before) + assertThat(state.selectedChartIndex) + .isEqualTo(CHART_DAYS - 3) + assertThat(state.selectedDay?.day) + .isEqualTo(dayLabel(27)) + assertThat(state.hasNextDay).isTrue() + } + + @Test + fun `when at the newest day, then stepping forward does nothing`() = + test { + givenLoaded(days = 30) + + viewModel.selectNextDay() + + assertThat(loadedState().selectedDayIndex) + .isEqualTo(29) + } + + @Test + fun `when at the oldest charted day, then stepping back does nothing`() = + test { + givenLoaded(days = 30) + repeat(CHART_DAYS) { viewModel.selectPreviousDay() } + + val state = loadedState() + + // The arrows are bounded by the chart window, not the whole history. + assertThat(state.selectedChartIndex).isEqualTo(0) + assertThat(state.selectedDay?.day) + .isEqualTo(dayLabel(30 - CHART_DAYS)) + } + + @Test + fun `when the post has no history, then there is no selected day`() = + test { + givenLoaded(days = 0) + + val state = loadedState() + + assertThat(state.selectedDayIndex).isEqualTo(-1) + assertThat(state.selectedDay).isNull() + assertThat(state.previousDay).isNull() + assertThat(state.chartDays).isEmpty() + } + + @Test + fun `when the oldest day is selected, then there is no previous day`() = + test { + givenLoaded(days = 1) + + val state = loadedState() + + assertThat(state.selectedDay).isNotNull + assertThat(state.previousDay).isNull() + } + + private fun loadedState() = + viewModel.uiState.value as PostStatsDetailUiState.Loaded + + private suspend fun givenLoaded(days: Int) { + givenFetchReturns( + PostViewsResult.Success( + createPostViewsData(days) + ) + ) + viewModel.loadData(TEST_POST_ID) + } + + private suspend fun givenFetchReturns( + result: PostViewsResult + ) { + whenever( + statsRepository.fetchPostViews( + TEST_SITE_ID, + TEST_POST_ID + ) + ).thenReturn(result) + } + + private fun createPostViewsData(days: Int) = + PostViewsData( + postId = TEST_POST_ID, + totalViews = 100L, + dailyViews = (0 until days).map { index -> + PostViewsDailyView( + day = dayLabel(index), + views = index.toLong() + ) + }, + weeks = emptyList(), + years = emptyList(), + averages = emptyList(), + post = null + ) + + private fun dayLabel(index: Int) = + "2026-08-%02d".format(index + 1) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_POST_ID = 42L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + private const val ERROR = "error" + + // Mirrors the window the view model charts. + private const val CHART_DAYS = 14 + } +} 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/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCaseTest.kt new file mode 100644 index 000000000000..ab6016771599 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCaseTest.kt @@ -0,0 +1,188 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +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 + +@ExperimentalCoroutinesApi +class StatsLatestPostUseCaseTest : BaseUnitTest() { + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var latestPostDataSource: + LatestPostDataSource + + @Mock + private lateinit var accountStore: AccountStore + + private lateinit var useCase: StatsLatestPostUseCase + + private val site = SiteModel().apply { + siteId = TEST_SITE_ID + } + + @Before + fun setUp() { + whenever(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + useCase = StatsLatestPostUseCase( + statsRepository, + latestPostDataSource, + accountStore + ) + } + + @Test + fun `when no access token, then errors without fetching`() = + test { + whenever(accountStore.accessToken) + .thenReturn(null) + + val result = useCase(site) + + assertThat(result).isInstanceOf( + LatestPostResult.Error::class.java + ) + verify(latestPostDataSource, never()) + .fetchLatestPublishedPost(any()) + } + + @Test + fun `when site has no posts, then no stats are fetched`() = + test { + whenever( + latestPostDataSource + .fetchLatestPublishedPost(site) + ).thenReturn(LatestPostLookupResult.NoPosts) + + val result = useCase(site) + + assertThat(result).isEqualTo( + LatestPostResult.NoPosts + ) + verify(statsRepository, never()) + .fetchPostViews(any(), any()) + } + + @Test + fun `when the lookup fails, then no stats are fetched`() = + test { + whenever( + latestPostDataSource + .fetchLatestPublishedPost(site) + ).thenReturn( + LatestPostLookupResult.Error("nope") + ) + + val result = useCase(site) + + assertThat(result).isInstanceOf( + LatestPostResult.Error::class.java + ) + verify(statsRepository, never()) + .fetchPostViews(any(), any()) + } + + @Test + fun `when the stats fetch fails, then the result is an error`() = + test { + givenLookupSucceeds() + givenStatsReturn(PostViewsResult.Error("boom")) + + val result = useCase(site) + + assertThat(result).isInstanceOf( + LatestPostResult.Error::class.java + ) + } + + @Test + fun `when both calls succeed, then stats and image are returned`() = + test { + givenLookupSucceeds() + val data = createPostViewsData() + givenStatsReturn(PostViewsResult.Success(data)) + + val result = useCase(site) + + assertThat(result).isEqualTo( + LatestPostResult.Success( + data = data, + featuredImageUrl = TEST_IMAGE_URL + ) + ) + } + + @Test + fun `when the post has no featured image, then the url is null`() = + test { + givenLookupSucceeds(imageUrl = null) + givenStatsReturn( + PostViewsResult.Success( + createPostViewsData() + ) + ) + + val result = useCase(site) + as LatestPostResult.Success + + assertThat(result.featuredImageUrl).isNull() + } + + private suspend fun givenLookupSucceeds( + imageUrl: String? = TEST_IMAGE_URL + ) { + whenever( + latestPostDataSource + .fetchLatestPublishedPost(site) + ).thenReturn( + LatestPostLookupResult.Success( + postId = TEST_POST_ID, + featuredImageUrl = imageUrl + ) + ) + } + + private suspend fun givenStatsReturn( + result: PostViewsResult + ) { + whenever( + statsRepository.fetchPostViews( + TEST_SITE_ID, + TEST_POST_ID + ) + ).thenReturn(result) + } + + private fun createPostViewsData() = PostViewsData( + postId = TEST_POST_ID, + totalViews = 10L, + dailyViews = emptyList(), + weeks = emptyList(), + years = emptyList(), + averages = emptyList(), + post = null + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_POST_ID = 42L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + private const val TEST_IMAGE_URL = + "https://example.com/image.jpg" + } +}