diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 6131afb24c14..53d025eebd62 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -142,6 +142,11 @@ android:theme="@style/WordPress.NoActionBar" android:exported="false" /> + + postType - else -> StatsConstants.ITEM_TYPE_HOME_PAGE -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt index 08f9a7f24a44..8721697d6d52 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt @@ -12,6 +12,9 @@ enum class InsightsCardType( ALL_TIME_STATS( R.string.stats_insights_all_time_stats_title ), + LATEST_POST( + R.string.stats_insights_latest_post_summary + ), MOST_POPULAR_DAY( R.string.stats_insights_most_popular_day ), @@ -27,6 +30,7 @@ enum class InsightsCardType( listOf( YEAR_IN_REVIEW, ALL_TIME_STATS, + LATEST_POST, MOST_POPULAR_DAY, MOST_POPULAR_TIME, TAGS_AND_CATEGORIES diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt index 2185da821754..b1474fbfe244 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt @@ -349,10 +349,11 @@ class InsightsViewModel @Inject constructor( } companion object { - // TAGS_AND_CATEGORIES is intentionally absent - // from both checks: it has its own dedicated - // fetch path via StatsTagsUseCase in - // TagsAndCategoriesViewModel. + // TAGS_AND_CATEGORIES and LATEST_POST are + // intentionally absent from both checks: each + // fetches on its own, from + // TagsAndCategoriesViewModel and + // LatestPostViewModel respectively. private fun List.needsSummary(): Boolean = any { it == InsightsCardType.ALL_TIME_STATS || diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 02f229bccab1..e44c2fc5f344 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -67,6 +67,7 @@ import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.ui.PagePostCreationSourcesDetail import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.components.FeedbackDialog import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -108,8 +109,11 @@ import org.wordpress.android.ui.newstats.subscribers.SubscribersTabContent import android.widget.Toast import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsCard import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsViewModel +import org.wordpress.android.ui.newstats.latestpost.LatestPostCard +import org.wordpress.android.ui.newstats.latestpost.LatestPostViewModel import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayCard import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayViewModel +import org.wordpress.android.ui.newstats.poststats.PostStatsDetailActivity import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeCard import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeViewModel import org.wordpress.android.ui.newstats.yearinreview.YearInReviewCard @@ -189,7 +193,8 @@ class NewStatsActivity : BaseAppCompatActivity() { onStatsUrlClick = { url -> activityNavigator.openInCustomTab(this, url) }, - onPostItemClick = ::openPostDetailStats + onPostItemClick = ::openPostDetailStats, + onCreatePostClick = ::createNewPost ) } } @@ -205,7 +210,21 @@ class NewStatsActivity : BaseAppCompatActivity() { } private fun openPostDetailStats(item: MostViewedItem) { - activityNavigator.openPostDetailStats(this, item.id, item.postType, item.title, item.url) + analyticsTracker.track(Stat.STATS_POSTS_AND_PAGES_ITEM_TAPPED) + PostStatsDetailActivity.start(this, item.id, item.title) + } + + private fun createNewPost() { + selectedSiteRepository.getSelectedSite()?.let { site -> + ActivityLauncher.addNewPostForResult( + this, + site, + false, + PagePostCreationSourcesDetail.POST_FROM_STATS, + -1, + null + ) + } } /** @@ -306,7 +325,8 @@ private fun NewStatsScreen( showIntroBottomSheet: Boolean = false, onIntroDismissed: () -> Unit = {}, onStatsUrlClick: (String) -> Unit = {}, - onPostItemClick: (MostViewedItem) -> Unit = {} + onPostItemClick: (MostViewedItem) -> Unit = {}, + onCreatePostClick: () -> Unit = {} ) { val viewsStatsViewModel: ViewsStatsViewModel = viewModel() val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState() @@ -448,7 +468,8 @@ private fun NewStatsScreen( tab = tabs[page], viewsStatsViewModel = viewsStatsViewModel, onStatsUrlClick = onStatsUrlClick, - onPostItemClick = onPostItemClick + onPostItemClick = onPostItemClick, + onCreatePostClick = onCreatePostClick ) } } @@ -460,7 +481,8 @@ private fun StatsTabContent( tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel, onStatsUrlClick: (String) -> Unit = {}, - onPostItemClick: (MostViewedItem) -> Unit = {} + onPostItemClick: (MostViewedItem) -> Unit = {}, + onCreatePostClick: () -> Unit = {} ) { when (tab) { StatsTab.TRAFFIC -> TrafficTabContent( @@ -469,7 +491,8 @@ private fun StatsTabContent( onPostItemClick = onPostItemClick ) StatsTab.INSIGHTS -> InsightsTabContent( - onStatsUrlClick = onStatsUrlClick + onStatsUrlClick = onStatsUrlClick, + onCreatePostClick = onCreatePostClick ) StatsTab.SUBSCRIBERS -> SubscribersTabContent() } @@ -1121,8 +1144,10 @@ private fun InsightsTabContent( mostPopularDayViewModel: MostPopularDayViewModel = viewModel(), mostPopularTimeViewModel: MostPopularTimeViewModel = viewModel(), tagsAndCategoriesViewModel: TagsAndCategoriesViewModel = viewModel(), + latestPostViewModel: LatestPostViewModel = viewModel(), insightsViewModel: InsightsViewModel = viewModel(), - onStatsUrlClick: (String) -> Unit = {} + onStatsUrlClick: (String) -> Unit = {}, + onCreatePostClick: () -> Unit = {} ) { val context = LocalContext.current val yearInReviewUiState by yearInReviewViewModel.uiState.collectAsState() @@ -1130,6 +1155,7 @@ private fun InsightsTabContent( val mostPopularDayUiState by mostPopularDayViewModel.uiState.collectAsState() val mostPopularTimeUiState by mostPopularTimeViewModel.uiState.collectAsState() val tagsAndCategoriesUiState by tagsAndCategoriesViewModel.uiState.collectAsState() + val latestPostUiState by latestPostViewModel.uiState.collectAsState() val isRefreshing by insightsViewModel.isDataRefreshing.collectAsState() val pullToRefreshState = rememberPullToRefreshState() @@ -1145,6 +1171,9 @@ private fun InsightsTabContent( if (InsightsCardType.TAGS_AND_CATEGORIES in cardsToLoad) { tagsAndCategoriesViewModel.loadData() } + if (InsightsCardType.LATEST_POST in cardsToLoad) { + latestPostViewModel.loadData() + } } val onRetryData = remember { { insightsViewModel.fetchData() } } @@ -1218,6 +1247,11 @@ private fun InsightsTabContent( ) { tagsAndCategoriesViewModel.refresh() } + if (InsightsCardType.LATEST_POST + in visibleCards + ) { + latestPostViewModel.refresh() + } }, indicator = { PullToRefreshDefaults.Indicator( @@ -1269,6 +1303,24 @@ private fun InsightsTabContent( onMoveDown = { insightsViewModel.moveCardDown(cardType) }, onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) } ) + InsightsCardType.LATEST_POST -> LatestPostCard( + uiState = latestPostUiState, + onRemoveCard = { insightsViewModel.removeCard(cardType) }, + onRetry = { latestPostViewModel.refresh() }, + onPostClick = { postId, title -> + PostStatsDetailActivity.start( + context, + postId, + title + ) + }, + onCreatePostClick = onCreatePostClick, + cardPosition = pos, + onMoveUp = { insightsViewModel.moveCardUp(cardType) }, + onMoveToTop = { insightsViewModel.moveCardToTop(cardType) }, + onMoveDown = { insightsViewModel.moveCardDown(cardType) }, + onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) } + ) InsightsCardType.MOST_POPULAR_DAY -> MostPopularDayCard( uiState = mostPopularDayUiState, onRemoveCard = { insightsViewModel.removeCard(cardType) }, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt index 3fbbf58c4b20..316c875356c0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsColors.kt @@ -9,4 +9,10 @@ import androidx.compose.ui.graphics.Color object StatsColors { val ChangeBadgePositive = Color(0xFF2E7D32) val ChangeBadgeNegative = Color(0xFFE91E63) + + /** + * The selected bar in a day-views chart. Shares the negative badge's hue, but it marks the + * user's selection, not a decline -- keep them separate so either can move independently. + */ + val ChartSelectedBar = Color(0xFFE91E63) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt new file mode 100644 index 000000000000..19a9a375e8d2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsBarChart.kt @@ -0,0 +1,71 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +// Zero-value entries still get a sliver of a bar so the series reads as continuous. +private const val MIN_BAR_FRACTION = 0.02f + +/** + * A compact bar chart of a single series, scaled against its own largest value. Renders nothing + * when every value is zero. + */ +@Composable +fun StatsBarChart( + values: List, + height: Dp, + barSpacing: Dp, + modifier: Modifier = Modifier +) { + val maxValue = values.maxOrNull() ?: 0L + if (maxValue <= 0L) return + + val barColor = MaterialTheme.colorScheme.primary + + Row( + modifier = modifier + .fillMaxWidth() + .height(height), + horizontalArrangement = Arrangement.spacedBy(barSpacing), + verticalAlignment = Alignment.Bottom + ) { + values.forEach { value -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + val fraction = ( + value.toFloat() / maxValue.toFloat() + ).coerceIn(MIN_BAR_FRACTION, 1f) + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction) + .clip( + RoundedCornerShape( + topStart = 2.dp, + topEnd = 2.dp + ) + ) + .background(barColor) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt new file mode 100644 index 000000000000..ca57c4b156fc --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsDayViewsChart.kt @@ -0,0 +1,124 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.data.columnModel +import com.patrykandpatrick.vico.compose.cartesian.layer.ColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.common.Fill +import com.patrykandpatrick.vico.compose.common.component.LineComponent +import org.wordpress.android.ui.newstats.StatsColors + +private const val BAR_CORNER_PERCENT = 20 +private val BarThickness = 16.dp + +/** + * A daily-views bar chart with value and date axes, highlighting one bar as the selected day. + * + * Vico colours a series rather than an individual bar, so the highlight is a second stacked series + * that is zero everywhere except the selected index. + */ +@Composable +fun StatsDayViewsChart( + values: List, + selectedIndex: Int, + height: Dp, + startLabel: String, + endLabel: String, + modifier: Modifier = Modifier +) { + if (values.isEmpty()) return + + val modelProducer = remember { CartesianChartModelProducer() } + + LaunchedEffect(values, selectedIndex) { + modelProducer.runTransaction { + columnModel { + series( + values.mapIndexed { index, value -> + if (index == selectedIndex) 0L else value + } + ) + series( + values.mapIndexed { index, value -> + if (index == selectedIndex) value else 0L + } + ) + } + } + } + + val barColor = MaterialTheme.colorScheme.primary + val highlightColor = StatsColors.ChartSelectedBar + // These are remember() keys for the layer and the chart, so rebuilding them every + // recomposition would rebuild the whole chart with them. + val columnProvider = remember(barColor, highlightColor) { + val barShape = RoundedCornerShape( + topStartPercent = BAR_CORNER_PERCENT, + topEndPercent = BAR_CORNER_PERCENT + ) + ColumnCartesianLayer.ColumnProvider.series( + LineComponent( + fill = Fill(barColor), + thickness = BarThickness, + shape = barShape + ), + LineComponent( + fill = Fill(highlightColor), + thickness = BarThickness, + shape = barShape + ) + ) + } + // Vico rejects blank labels, so the item placer -- not the formatter -- decides that only + // the two ends are labelled. + val bottomAxisValueFormatter = + remember(startLabel, endLabel) { + CartesianValueFormatter { _, x, _ -> + if (x <= 0.0) startLabel else endLabel + } + } + val endsOnlyItemPlacer = remember(values.size) { + HorizontalAxis.ItemPlacer.aligned( + spacing = { values.lastIndex.coerceAtLeast(1) } + ) + } + + CartesianChartHost( + chart = rememberCartesianChart( + rememberColumnCartesianLayer( + columnProvider = columnProvider, + mergeMode = { + ColumnCartesianLayer.MergeMode.Stacked + } + ), + startAxis = VerticalAxis.rememberStart(line = null), + bottomAxis = HorizontalAxis.rememberBottom( + valueFormatter = bottomAxisValueFormatter, + itemPlacer = endsOnlyItemPlacer + ) + ), + modelProducer = modelProducer, + scrollState = rememberVicoScrollState( + scrollEnabled = false + ), + modifier = modifier + .fillMaxWidth() + .height(height) + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt new file mode 100644 index 000000000000..3e30f2bba4e7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsLabeledValue.kt @@ -0,0 +1,46 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.wordpress.android.ui.newstats.util.THOUSAND +import org.wordpress.android.ui.newstats.util.formatStatValue + +/** + * A stat shown as a small label above its formatted value, for the side-by-side rows of + * views/likes/comments on the Latest Post card and the post stats screen. + * + * [abbreviateFrom] is passed through to [formatStatValue] so a screen can apply one abbreviation + * threshold to its header and its tables alike. + */ +@Composable +fun StatsLabeledValue( + @StringRes labelResId: Int, + value: Long, + modifier: Modifier = Modifier, + abbreviateFrom: Int = THOUSAND +) { + Column(modifier = modifier) { + Text( + text = stringResource(labelResId), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatStatValue(value, abbreviateFrom), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt new file mode 100644 index 000000000000..af248744d51f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/LatestPostDataSource.kt @@ -0,0 +1,139 @@ +package org.wordpress.android.ui.newstats.datasource + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import rs.wordpress.api.kotlin.WpApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.MediaId +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostListParams +import uniffi.wp_api.PostStatus +import uniffi.wp_api.SparseAnyPostFieldWithViewContext +import uniffi.wp_api.SparseMediaFieldWithViewContext +import uniffi.wp_api.WpApiParamOrder +import uniffi.wp_api.WpApiParamPostsOrderBy +import javax.inject.Inject + +/** + * Looks up the site's most recently published post. + * + * The stats API needs a post ID before it can return per-post views, and the WP.com stats surface + * has no posts endpoint of its own, so this goes through the site's REST API instead. + * + * [WpApiClientProvider.getWpApiClient] routes WP.com and Jetpack sites through the WP.com REST + * proxy using the account's OAuth token, so no application password is needed for the sites that + * have stats in the first place. + */ +class LatestPostDataSource @Inject constructor( + private val wpApiClientProvider: WpApiClientProvider +) { + suspend fun fetchLatestPublishedPost( + site: SiteModel + ): LatestPostLookupResult { + val params = PostListParams( + perPage = 1u, + order = WpApiParamOrder.DESC, + orderby = WpApiParamPostsOrderBy.DATE, + status = listOf(PostStatus.Publish) + ) + + // Only the id and featured image are needed; without the field filter the response + // carries the post's whole rendered content, excerpt and taxonomy payload. + val client = wpApiClientProvider.getWpApiClient(site) + val result = client.request { requestBuilder -> + requestBuilder.posts() + .filterListWithViewContext( + postEndpointType = + PostEndpointType.Posts, + params = params, + fields = listOf( + SparseAnyPostFieldWithViewContext.ID, + SparseAnyPostFieldWithViewContext + .FEATURED_MEDIA + ) + ) + } + + return when (result) { + is WpRequestResult.Success -> { + val post = result.response.data.firstOrNull() + val postId = post?.id + if (postId == null) { + LatestPostLookupResult.NoPosts + } else { + LatestPostLookupResult.Success( + postId = postId, + featuredImageUrl = post.featuredMedia + ?.let { fetchImageUrl(client, it) } + ) + } + } + else -> { + val message = ( + result as? WpRequestResult.WpError<*> + )?.errorMessage + ?: "Failed to fetch the latest post" + AppLog.e( + T.STATS, + "LatestPostDataSource: " + + "fetchLatestPublishedPost " + + "failed - $message" + ) + LatestPostLookupResult.Error(message) + } + } + } + + /** + * Resolves a featured image's URL. The post carries only the attachment id, so this is a + * second call -- made only when there is an image. A failure here costs the thumbnail, not + * the card, so it degrades to null rather than propagating. + */ + private suspend fun fetchImageUrl( + client: WpApiClient, + mediaId: MediaId + ): String? { + val result = client.request { requestBuilder -> + requestBuilder.media() + .filterRetrieveWithViewContext( + mediaId = mediaId, + fields = listOf( + SparseMediaFieldWithViewContext + .SOURCE_URL + ) + ) + } + return when (result) { + is WpRequestResult.Success -> + result.response.data.sourceUrl + else -> { + AppLog.w( + T.STATS, + "LatestPostDataSource: could not " + + "resolve featured image $mediaId" + ) + null + } + } + } +} + +/** + * Result of the latest-post lookup. [NoPosts] is a success -- the site simply has nothing + * published yet -- and is shown as an empty state rather than an error. + */ +sealed class LatestPostLookupResult { + data class Success( + val postId: Long, + /** Null when the post has no featured image, or when resolving its URL failed. */ + val featuredImageUrl: String? + ) : LatestPostLookupResult() + + data object NoPosts : LatestPostLookupResult() + + data class Error( + val message: String + ) : LatestPostLookupResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index bcac4ff6fd42..8e5504aa985b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -245,6 +245,18 @@ interface StatsDataSource { max: Int = 10 ): StatsTagsDataResult + /** + * Fetches view stats for a single post. + * + * @param siteId The WordPress.com site ID + * @param postId The post ID; 0 is the site's home page + * @return Result containing the post's view data or an error + */ + suspend fun fetchPostViews( + siteId: Long, + postId: Long + ): PostViewsDataResult + /** * Fetches subscriber count stats for a specific site. * @@ -422,8 +434,7 @@ data class TopPostDataItem( val id: Long, val title: String, val views: Long, - val url: String? = null, - val postType: String? = null + val url: String? = null ) /** @@ -764,6 +775,103 @@ data class TagData( val link: String? = null ) +/** + * Result wrapper for the per-post views fetch operation. + */ +sealed class PostViewsDataResult { + data class Success( + val data: PostViewsData + ) : PostViewsDataResult() + data class Error( + val errorType: StatsErrorType + ) : PostViewsDataResult() +} + +/** + * View stats for a single post, along with the post's own metadata. The API returns both in one + * response, so no separate post fetch is needed. + * + * Post ID 0 is the site's home page, which isn't a post -- it has view stats but no [post]. + */ +data class PostViewsData( + val postId: Long, + val totalViews: Long, + /** The post's complete daily view history, oldest first. */ + val dailyViews: List, + /** The most recent weeks of daily views, most recent first. */ + val weeks: List, + /** Yearly totals, most recent year first. */ + val years: List, + /** Yearly averages, most recent year first. */ + val averages: List, + /** The post these stats belong to, or null for the site's home page. */ + val post: PostViewsPost? +) + +/** + * A single day's view count. The day is kept so the chart can label its range. + */ +data class PostViewsDailyView( + /** The day the views were recorded on (format: yyyy-MM-dd). */ + val day: String, + val views: Long +) + +/** + * The editorial metadata of the post whose stats were fetched. + */ +data class PostViewsPost( + val title: String, + /** Publication date in the site's timezone (format: yyyy-MM-dd HH:mm:ss). */ + val date: String, + val likeCount: Long, + val commentCount: Long +) + +/** + * A week's view total and how it compares with the week before. + * + * [startDay] and [endDay] bound the week (format: yyyy-MM-dd); the final week may be partial. The + * API also breaks the week down per day, which nothing displays yet. + */ +data class PostViewsWeek( + val startDay: String, + val endDay: String, + val total: Long, + val change: PostViewsChange +) + +/** + * How a week's views compare with the week before. + */ +sealed class PostViewsChange { + data class Percentage( + val value: Double + ) : PostViewsChange() + + /** The previous week had no views, so the API reports an unbounded change. */ + data object Infinite : PostViewsChange() + + /** The week has no predecessor to compare against. */ + data object None : PostViewsChange() +} + +/** + * A year's view total. The API also breaks this down by month, which nothing displays yet. + */ +data class PostViewsYear( + val year: String, + val total: Long +) + +/** + * A year's average daily views. The API also breaks this down by month, which nothing displays yet. + */ +data class PostViewsYearAverage( + val year: String, + val overall: Double +) + /** * Result wrapper for stats subscribers fetch operation. */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 4e374950980f..4bba9ce723ac 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -21,6 +21,9 @@ import uniffi.wp_api.StatsRegionViewsPeriod import uniffi.wp_api.StatsDevicesParams import uniffi.wp_api.StatsDevicesPeriod import uniffi.wp_api.StatsInsightsParams +import uniffi.wp_api.StatsPostChange +import uniffi.wp_api.StatsPostResponse +import uniffi.wp_api.StatsPostWeek import uniffi.wp_api.StatsTagsParams import uniffi.wp_api.StatsSearchTermsParams import uniffi.wp_api.StatsSearchTermsPeriod @@ -206,8 +209,7 @@ class StatsDataSourceImpl @Inject constructor( id = post.id.toLong(), title = post.title.orEmpty(), views = post.views?.toLong() ?: 0L, - url = post.href, - postType = post.postType + url = post.href ) } ) @@ -1243,6 +1245,107 @@ class StatsDataSourceImpl @Inject constructor( } } + override suspend fun fetchPostViews( + siteId: Long, + postId: Long + ): PostViewsDataResult { + // The endpoint takes no query params -- num, date and period are silently ignored. + val result = getOrCreateClient() + .request { requestBuilder -> + requestBuilder.statsPost() + .getStatsPost( + wpComSiteId = siteId.toULong(), + postId = postId + ) + } + + logResultType("fetchPostViews", result) + + return when (result) { + is WpRequestResult.Success -> { + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchPostViews success" + ) + PostViewsDataResult.Success( + mapToPostViewsData( + result.response.data, + postId + ) + ) + } + else -> logErrorAndReturn( + "fetchPostViews", + result + ) { + PostViewsDataResult.Error(it) + } + } + } + + private fun mapToPostViewsData( + response: StatsPostResponse, + postId: Long + ): PostViewsData = PostViewsData( + // The home page has no post row to read an id from, so the requested id is authoritative. + postId = response.post?.id ?: postId, + totalViews = response.views.toLong(), + dailyViews = response.dailyViews + .map { + PostViewsDailyView( + day = it.period, + views = it.views.toLong() + ) + }, + // The API sends weeks oldest first; the UI lists + // the most recent week at the top. + weeks = response.weeks + .map { it.toPostViewsWeek() } + .reversed(), + years = response.years + .map { (year, value) -> + PostViewsYear( + year = year, + total = value.total.toLong() + ) + } + .sortedByDescending { it.year }, + averages = response.averages + .map { (year, value) -> + PostViewsYearAverage( + year = year, + overall = value.overall + ) + } + .sortedByDescending { it.year }, + // Null for the home page, which has view stats but no post metadata, likes or comments. + post = response.post?.let { post -> + PostViewsPost( + title = post.title, + date = post.date, + likeCount = response.likeCount + ?.toLong() ?: 0L, + commentCount = response.discussion + ?.commentCount?.toLong() ?: 0L + ) + } + ) + + private fun StatsPostWeek.toPostViewsWeek() = + PostViewsWeek( + startDay = days.firstOrNull()?.day.orEmpty(), + endDay = days.lastOrNull()?.day.orEmpty(), + total = total.toLong(), + change = when (val change = change) { + is StatsPostChange.Percentage -> + PostViewsChange.Percentage(change.value) + is StatsPostChange.Infinite -> + PostViewsChange.Infinite + null -> PostViewsChange.None + } + ) + override suspend fun fetchStatsSubscribers( siteId: Long, quantity: Int, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt new file mode 100644 index 000000000000..2b571839e8a9 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCard.kt @@ -0,0 +1,327 @@ +package org.wordpress.android.ui.newstats.latestpost + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.newstats.components.CardPosition +import org.wordpress.android.ui.newstats.components.StatsBarChart +import org.wordpress.android.ui.newstats.components.StatsCardContainer +import org.wordpress.android.ui.newstats.components.StatsCardErrorContent +import org.wordpress.android.ui.newstats.components.StatsCardHeader +import org.wordpress.android.ui.newstats.components.StatsLabeledValue +import org.wordpress.android.ui.newstats.util.ShimmerBox + +private val CardPadding = 16.dp +// Matches the featured image on the old Latest Post Summary card. +private val FeaturedImageSize = 68.dp +private val FeaturedImageCorner = 8.dp +private val ChartHeight = 48.dp + +@Composable +@Suppress("LongParameterList") +fun LatestPostCard( + uiState: LatestPostCardUiState, + onRemoveCard: () -> Unit, + onRetry: () -> Unit, + onPostClick: (postId: Long, title: String) -> Unit, + onCreatePostClick: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + when (uiState) { + is LatestPostCardUiState.Loading -> + LoadingContent() + is LatestPostCardUiState.NoData -> + NoDataContent( + onCreatePostClick, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is LatestPostCardUiState.Loaded -> + LoadedContent( + uiState, + onPostClick, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + LatestPostCardUiState.Error -> + StatsCardErrorContent( + titleResId = R.string + .stats_insights_latest_post_summary, + errorMessageResId = + R.string.stats_error_api, + onRetry = onRetry, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + ShimmerBar(width = 180.dp, height = 24.dp) + Spacer(modifier = Modifier.height(20.dp)) + ShimmerBar(width = 240.dp, height = 20.dp) + Spacer(modifier = Modifier.height(8.dp)) + ShimmerBar(width = 100.dp, height = 14.dp) + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + repeat(STAT_COLUMN_COUNT) { + ShimmerBar(width = 60.dp, height = 40.dp) + } + } + Spacer(modifier = Modifier.height(20.dp)) + ShimmerBar( + width = 240.dp, + height = ChartHeight + ) + } +} + +@Composable +private fun ShimmerBar(width: Dp, height: Dp) { + ShimmerBox( + modifier = Modifier + .width(width) + .height(height) + ) +} + +@Suppress("LongParameterList") +@Composable +private fun NoDataContent( + onCreatePostClick: () -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = R.string + .stats_insights_latest_post_summary, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource( + R.string.stats_insights_latest_post_empty + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + Button(onClick = onCreatePostClick) { + Text( + text = stringResource( + R.string.stats_insights_create_post + ) + ) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: LatestPostCardUiState.Loaded, + onPostClick: (postId: Long, title: String) -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + onPostClick( + state.postId, + state.postTitle + ) + } + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = R.string + .stats_insights_latest_post_summary, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.postTitle, + style = MaterialTheme.typography + .titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme + .onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = state.postDate, + style = MaterialTheme.typography + .bodySmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } + if (state.featuredImageUrl != null) { + Spacer(modifier = Modifier.width(12.dp)) + AsyncImage( + model = state.featuredImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(FeaturedImageSize) + .clip( + RoundedCornerShape( + FeaturedImageCorner + ) + ) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + StatsLabeledValue( + labelResId = R.string.stats_views, + value = state.views, + modifier = Modifier.weight(1f) + ) + StatsLabeledValue( + labelResId = R.string.stats_likes, + value = state.likes, + modifier = Modifier.weight(1f) + ) + StatsLabeledValue( + labelResId = R.string.stats_comments, + value = state.comments, + modifier = Modifier.weight(1f) + ) + } + if (state.recentViews.any { it > 0L }) { + Spacer(modifier = Modifier.height(16.dp)) + StatsBarChart( + values = state.recentViews, + height = ChartHeight, + barSpacing = 4.dp + ) + } + } +} + +private const val STAT_COLUMN_COUNT = 3 + +@Preview(showBackground = true) +@Composable +private fun LatestPostCardLoadedPreview() { + AppThemeM3 { + LatestPostCard( + uiState = LatestPostCardUiState.Loaded( + postId = 2729L, + postTitle = "Ten things I learned " + + "building a birdhouse", + postDate = "Aug 4, 2026", + views = 4600L, + likes = 32L, + comments = 7L, + recentViews = listOf( + 12L, 40L, 33L, 80L, 65L, 21L, 54L + ), + featuredImageUrl = null + ), + onRemoveCard = {}, + onRetry = {}, + onPostClick = { _, _ -> }, + onCreatePostClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun LatestPostCardErrorPreview() { + AppThemeM3 { + LatestPostCard( + uiState = LatestPostCardUiState.Error, + onRemoveCard = {}, + onRetry = {}, + onPostClick = { _, _ -> }, + onCreatePostClick = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt new file mode 100644 index 000000000000..7a5a2c4aad05 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostCardUiState.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.ui.newstats.latestpost + +sealed class LatestPostCardUiState { + data object Loading : LatestPostCardUiState() + + /** The site has no published posts yet. */ + data object NoData : LatestPostCardUiState() + + data class Loaded( + val postId: Long, + val postTitle: String, + val postDate: String, + val views: Long, + val likes: Long, + val comments: Long, + /** Daily views for the trailing week, oldest first. */ + val recentViews: List, + /** Null when the post has no featured image. */ + val featuredImageUrl: String? + ) : LatestPostCardUiState() + + /** + * The card shows a fixed error message and a retry action, so the underlying failure isn't + * carried here -- it's already logged at the point it happens. + */ + data object Error : LatestPostCardUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt new file mode 100644 index 000000000000..7129f24c900e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/latestpost/LatestPostViewModel.kt @@ -0,0 +1,137 @@ +package org.wordpress.android.ui.newstats.latestpost + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.LatestPostResult +import org.wordpress.android.ui.newstats.repository.StatsLatestPostUseCase +import org.wordpress.android.ui.newstats.util.formatStatsDateTime +import org.wordpress.android.util.AppLog +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException +import javax.inject.Inject + +@HiltViewModel +class LatestPostViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val statsLatestPostUseCase: + StatsLatestPostUseCase +) : ViewModel() { + private val _uiState = + MutableStateFlow( + LatestPostCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val isLoaded = AtomicBoolean(false) + private val isLoading = AtomicBoolean(false) + // Main-thread-confined: only accessed from + // viewModelScope (Dispatchers.Main). + private var fetchJob: Job? = null + + fun loadData() { + if (isLoaded.get() || + !isLoading.compareAndSet(false, true) + ) return + fetchData() + } + + fun refresh() { + fetchJob?.cancel() + isLoaded.set(false) + isLoading.set(true) + _uiState.value = LatestPostCardUiState.Loading + fetchData() + } + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + private fun fetchData() { + val site = selectedSiteRepository + .getSelectedSite() + if (site == null) { + isLoading.set(false) + _uiState.value = LatestPostCardUiState.Error + return + } + + fetchJob = viewModelScope.launch { + try { + val result = + statsLatestPostUseCase(site) + isLoaded.set( + result !is LatestPostResult.Error + ) + handleResult(result) + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching latest post: " + + "${e.message}", + e + ) + isLoaded.set(false) + _uiState.value = + LatestPostCardUiState.Error + } finally { + isLoading.set(false) + } + } + } + + private fun handleResult(result: LatestPostResult) { + _uiState.value = when (result) { + is LatestPostResult.Success -> { + val views = result.data + val post = views.post + // The card always asks for a published post, so a missing post row means the + // response wasn't what we asked for -- treat it as an error rather than + // rendering a card with no title. + if (post == null) { + AppLog.w( + AppLog.T.STATS, + "Latest post stats had no post row " + + "for id ${views.postId}" + ) + LatestPostCardUiState.Error + } else { + LatestPostCardUiState.Loaded( + postId = views.postId, + postTitle = post.title, + postDate = formatStatsDateTime( + post.date + ), + views = views.totalViews, + likes = post.likeCount, + comments = post.commentCount, + recentViews = views.dailyViews + .takeLast(CARD_CHART_DAYS) + .map { it.views }, + featuredImageUrl = + result.featuredImageUrl + ) + } + } + is LatestPostResult.NoPosts -> + LatestPostCardUiState.NoData + is LatestPostResult.Error -> + LatestPostCardUiState.Error + } + } + + companion object { + // A week of daily views is all the card charts. + private const val CARD_CHART_DAYS = 7 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt index c5005be305b7..a6487f99fcdf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt @@ -40,7 +40,6 @@ sealed class MostViewedCardUiState { * @param change The percentage change compared to previous period * @param url The item's URL. Posts open it as detail stats; referrers and clicks open it in a * Custom Tab. Null when the item has no link of its own. - * @param postType The API post type, e.g. "post"/"page" (posts only) */ data class MostViewedItem( val id: Long, @@ -48,8 +47,7 @@ data class MostViewedItem( val views: Long, val change: MostViewedChange, val children: List = emptyList(), - val url: String? = null, - val postType: String? = null + val url: String? = null ) /** @@ -107,6 +105,5 @@ data class MostViewedDetailItem( val views: Long, val change: MostViewedChange, val children: List = emptyList(), - val url: String? = null, - val postType: String? = null + val url: String? = null ) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt index 83c02b14e5d1..430bc6356ab0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.unit.dp import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.ui.newstats.poststats.PostStatsDetailActivity import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity @@ -49,6 +51,7 @@ import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.util.extensions.getParcelableArrayListCompat import org.wordpress.android.util.extensions.getSerializableCompat +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper import javax.inject.Inject private const val EXTRA_CARD_TYPE = "extra_card_type" @@ -72,6 +75,8 @@ private const val NO_EPOCH_DAY = -1L class MostViewedDetailActivity : BaseAppCompatActivity() { @Inject lateinit var activityNavigator: ActivityNavigator + @Inject lateinit var analyticsTracker: AnalyticsTrackerWrapper + private val viewModel: MostViewedDetailViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -114,7 +119,8 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { } private fun openPostDetailStats(item: MostViewedDetailItem) { - activityNavigator.openPostDetailStats(this, item.id, item.postType, item.title, item.url) + analyticsTracker.track(Stat.STATS_POSTS_AND_PAGES_ITEM_TAPPED) + PostStatsDetailActivity.start(this, item.id, item.title) } /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt index e66288db8ed9..b0d16f7940c9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt @@ -228,8 +228,7 @@ class MostViewedViewModel @Inject constructor( views = item.views, change = item.change, children = item.children, - url = item.url, - postType = item.postType + url = item.url ) }, maxViewsForBar = cardItems.firstOrNull()?.views ?: 1L @@ -341,7 +340,6 @@ internal fun MostViewedItemData.toDetailItem(): MostViewedDetailItem { children = children.map { child -> MostViewedChildItem(name = child.name, url = child.url, views = child.views) }, - url = url, - postType = postType + url = url ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt new file mode 100644 index 000000000000..9c7e0e7e4708 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailActivity.kt @@ -0,0 +1,596 @@ +package org.wordpress.android.ui.newstats.poststats + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.ui.newstats.StatsColors +import org.wordpress.android.ui.newstats.components.StatsDayViewsChart +import org.wordpress.android.ui.newstats.components.StatsChangeIndicator +import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.components.StatsLabeledValue +import org.wordpress.android.ui.newstats.components.StatsViewChange +import org.wordpress.android.ui.newstats.datasource.PostViewsChange +import org.wordpress.android.ui.newstats.datasource.PostViewsDailyView +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import org.wordpress.android.ui.newstats.datasource.PostViewsWeek +import org.wordpress.android.ui.newstats.util.ShimmerBox +import org.wordpress.android.ui.newstats.util.formatChangePercentage +import org.wordpress.android.ui.newstats.util.TEN_THOUSAND +import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.ui.newstats.util.formatStatsDate +import org.wordpress.android.ui.newstats.util.formatStatsDateTime + +private val ScreenPadding = 16.dp +private val ChartHeight = 120.dp +private const val LOADING_SHIMMER_ITEM_COUNT = 6 + +@AndroidEntryPoint +class PostStatsDetailActivity : BaseAppCompatActivity() { + private val viewModel: PostStatsDetailViewModel + by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // 0 is a real id (the home page), so it can't stand in for a missing extra. + val postId = intent.getLongExtra(ARG_POST_ID, NO_POST_ID) + val postTitle = intent + .getStringExtra(ARG_POST_TITLE).orEmpty() + // The view model survives recreation, so only the first creation kicks off the fetch -- + // otherwise every rotation or theme change refetches the post's whole view history. + if (savedInstanceState == null) { + viewModel.loadData(postId) + } + + setContent { + AppThemeM3 { + val uiState by viewModel.uiState + .collectAsState() + PostStatsDetailScreen( + uiState = uiState, + postTitle = postTitle, + onBackPressed = + onBackPressedDispatcher + ::onBackPressed, + onRetry = { viewModel.loadData(postId) }, + onPreviousDay = viewModel::selectPreviousDay, + onNextDay = viewModel::selectNextDay + ) + } + } + } + + companion object { + private const val ARG_POST_ID = "post_id" + private const val NO_POST_ID = -1L + private const val ARG_POST_TITLE = "post_title" + + /** + * @param postId the post to show stats for; 0 is the site's home page + * @param postTitle shown until the response arrives, and kept as the title for the home + * page, whose stats carry no post metadata + */ + fun start( + context: Context, + postId: Long, + postTitle: String + ) { + context.startActivity( + Intent( + context, + PostStatsDetailActivity::class.java + ) + .putExtra(ARG_POST_ID, postId) + .putExtra(ARG_POST_TITLE, postTitle) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongParameterList") +private fun PostStatsDetailScreen( + uiState: PostStatsDetailUiState, + postTitle: String, + onBackPressed: () -> Unit, + onRetry: () -> Unit, + onPreviousDay: () -> Unit, + onNextDay: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource( + R.string.stats_post_detail_title + ) + ) + }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + imageVector = Icons + .AutoMirrored + .Filled.ArrowBack, + contentDescription = + stringResource( + R.string.back + ) + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when (uiState) { + is PostStatsDetailUiState.Loading -> + LoadingContent() + is PostStatsDetailUiState.Error -> + ErrorContent(uiState.message, onRetry) + is PostStatsDetailUiState.Loaded -> + LoadedContent( + state = uiState, + fallbackTitle = postTitle, + onPreviousDay = onPreviousDay, + onNextDay = onNextDay + ) + } + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(ScreenPadding), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + repeat(LOADING_SHIMMER_ITEM_COUNT) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + ) + } + } +} + +@Composable +private fun ErrorContent( + message: String, + onRetry: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(ScreenPadding), + horizontalAlignment = + Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +private fun LoadedContent( + state: PostStatsDetailUiState.Loaded, + fallbackTitle: String, + onPreviousDay: () -> Unit, + onNextDay: () -> Unit +) { + val data = state.data + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(ScreenPadding) + ) { + item { PostHeader(data, fallbackTitle) } + item { Spacer(modifier = Modifier.height(24.dp)) } + + val selectedDay = state.selectedDay + if (selectedDay != null) { + item { + DaySelector( + day = selectedDay, + hasPreviousDay = + state.previousDay != null, + hasNextDay = state.hasNextDay, + onPreviousDay = onPreviousDay, + onNextDay = onNextDay + ) + SelectedDayViews( + views = selectedDay.views, + previousViews = state.previousDay?.views + ) + DayViewsChart( + days = state.chartDays, + selectedIndex = state.selectedChartIndex + ) + Spacer( + modifier = Modifier.height(24.dp) + ) + } + } + + statsSection( + R.string.stats_detail_recent_weeks, + data.weeks + ) { WeekRow(it) } + + statsSection( + R.string.stats_detail_months_and_years, + data.years + ) { + DetailRow( + label = it.year, + value = formatStatValue(it.total, TEN_THOUSAND) + ) + } + + statsSection( + R.string.stats_detail_average_views_per_day, + data.averages + ) { + DetailRow( + label = it.year, + value = formatStatValue(it.overall) + ) + } + } +} + +/** + * A titled list section, skipped entirely when [items] is empty. + */ +private fun LazyListScope.statsSection( + @StringRes titleResId: Int, + items: List, + row: @Composable (T) -> Unit +) { + if (items.isEmpty()) return + item { + SectionTitle(titleResId) + StatsListHeader( + leftHeaderResId = + R.string.stats_months_and_years_period_label, + rightHeaderResId = + R.string.stats_months_and_years_views_label + ) + } + items(items.size) { index -> + if (index > 0) HorizontalDivider() + row(items[index]) + } + item { Spacer(modifier = Modifier.height(24.dp)) } +} + +/** + * The title, date and engagement counts belong to the post. The home page has none of them, so it + * falls back to the title the caller supplied and shows views alone. + */ +@Composable +private fun PostHeader(data: PostViewsData, fallbackTitle: String) { + val post = data.post + Column { + Text( + text = post?.title?.takeIf { it.isNotBlank() } + ?: fallbackTitle, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + if (post != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatStatsDateTime(post.date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(24.dp) + ) { + StatsLabeledValue( + labelResId = R.string.stats_views, + value = data.totalViews, + abbreviateFrom = TEN_THOUSAND + ) + if (post != null) { + StatsLabeledValue( + labelResId = R.string.stats_likes, + value = post.likeCount, + abbreviateFrom = TEN_THOUSAND + ) + StatsLabeledValue( + labelResId = R.string.stats_comments, + value = post.commentCount, + abbreviateFrom = TEN_THOUSAND + ) + } + } + } +} + +/** + * The selected day with arrows to step through the history, mirroring the old stats date bar. + */ +@Composable +private fun DaySelector( + day: PostViewsDailyView, + hasPreviousDay: Boolean, + hasNextDay: Boolean, + onPreviousDay: () -> Unit, + onNextDay: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatStatsDate(day.day), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = onPreviousDay, + enabled = hasPreviousDay + ) { + Icon( + imageVector = Icons.AutoMirrored + .Filled.KeyboardArrowLeft, + contentDescription = stringResource( + R.string.stats_previous_day + ) + ) + } + IconButton( + onClick = onNextDay, + enabled = hasNextDay + ) { + Icon( + imageVector = Icons.AutoMirrored + .Filled.KeyboardArrowRight, + contentDescription = stringResource( + R.string.stats_next_day + ) + ) + } + } +} + +/** + * The selected day's views and how they compare with the day before. + */ +@Composable +private fun SelectedDayViews( + views: Long, + previousViews: Long? +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = formatStatValue(views, TEN_THOUSAND), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.stats_views), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + modifier = Modifier + .padding(start = 8.dp, bottom = 2.dp) + .weight(1f) + ) + if (previousViews != null) { + DayChangeLabel( + views = views, + previous = previousViews + ) + } + } +} + +@Composable +private fun DayChangeLabel(views: Long, previous: Long) { + val difference = views - previous + val percentage = when { + previous == views -> formatChangePercentage(0.0) + previous == 0L -> INFINITY + else -> formatChangePercentage( + difference.toDouble() / previous + ) + } + val positive = difference >= 0 + Text( + text = stringResource( + if (positive) { + R.string.stats_traffic_increase + } else { + R.string.stats_traffic_change + }, + formatStatValue(difference, TEN_THOUSAND), + percentage + ), + style = MaterialTheme.typography.bodyMedium, + color = if (positive) { + StatsColors.ChangeBadgePositive + } else { + StatsColors.ChangeBadgeNegative + } + ) +} + +@Composable +private fun DayViewsChart( + days: List, + selectedIndex: Int +) { + // Derived once per window so the chart's remember() keys stay stable across recompositions, + // and so no date parsing happens on Vico's measure/draw path. + val values = remember(days) { days.map { it.views } } + val startLabel = remember(days) { + formatStatsDate(days.first().day) + } + val endLabel = remember(days) { + formatStatsDate(days.last().day) + } + StatsDayViewsChart( + values = values, + selectedIndex = selectedIndex, + height = ChartHeight, + startLabel = startLabel, + endLabel = endLabel, + modifier = Modifier.padding(top = 8.dp) + ) +} + +@Composable +private fun SectionTitle(titleResId: Int) { + Text( + text = stringResource(titleResId), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) +} + +@Composable +private fun WeekRow(week: PostViewsWeek) { + DetailRow( + label = weekLabel(week), + value = formatStatValue(week.total, TEN_THOUSAND) + ) { + when (val change = week.change) { + is PostViewsChange.Infinite -> Text( + text = INFINITY, + style = MaterialTheme.typography + .labelMedium, + color = MaterialTheme.colorScheme.primary + ) + is PostViewsChange.Percentage -> + StatsChangeIndicator( + change = change.value.toViewChange() + ) + is PostViewsChange.None -> Unit + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + trailing: @Composable () -> Unit = {} +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + trailing() + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp) + ) + } +} + +private fun weekLabel(week: PostViewsWeek): String { + val start = formatStatsDate(week.startDay) + val end = formatStatsDate(week.endDay) + return if (start == end) start else "$start - $end" +} + +// StatsChangeIndicator renders only the percentage, so the delta the sealed class also carries is +// not something this screen has -- a week's change arrives as a percentage, not a view count. +private fun Double.toViewChange(): StatsViewChange = when { + this > 0 -> StatsViewChange.Positive(0L, this) + this < 0 -> StatsViewChange.Negative(0L, -this) + else -> StatsViewChange.NoChange +} + +private const val INFINITY = "∞" diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt new file mode 100644 index 000000000000..6301fec5fdea --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/poststats/PostStatsDetailViewModel.kt @@ -0,0 +1,163 @@ +package org.wordpress.android.ui.newstats.poststats + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import org.wordpress.android.ui.newstats.datasource.PostViewsDailyView +import org.wordpress.android.ui.newstats.repository.PostViewsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.util.AppLog +import org.wordpress.android.viewmodel.ResourceProvider +import kotlin.coroutines.cancellation.CancellationException +import javax.inject.Inject + +@HiltViewModel +class PostStatsDetailViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val statsRepository: StatsRepository, + private val accountStore: AccountStore, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + PostStatsDetailUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + fun loadData(postId: Long) { + val site = selectedSiteRepository.getSelectedSite() + val token = accountStore.accessToken + if (site == null || token.isNullOrEmpty()) { + _uiState.value = PostStatsDetailUiState.Error( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + return + } + statsRepository.init(token) + + _uiState.value = PostStatsDetailUiState.Loading + viewModelScope.launch { + try { + val result = + statsRepository.fetchPostViews( + siteId = site.siteId, + postId = postId + ) + _uiState.value = when (result) { + is PostViewsResult.Success -> + PostStatsDetailUiState.Loaded( + data = result.data, + // The newest day is selected first, as the old stats screen does. + selectedDayIndex = result.data + .dailyViews.lastIndex + ) + is PostViewsResult.Error -> + PostStatsDetailUiState.Error( + resourceProvider.getString( + R.string.stats_error_api + ) + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching post views: " + + "${e.message}", + e + ) + _uiState.value = + PostStatsDetailUiState.Error( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ) + } + } + } + + /** Moves the selected day one step towards the start of the history. */ + fun selectPreviousDay() = shiftSelectedDay(-1) + + /** Moves the selected day one step towards the present. */ + fun selectNextDay() = shiftSelectedDay(1) + + private fun shiftSelectedDay(offset: Int) { + _uiState.update { state -> + if (state !is PostStatsDetailUiState.Loaded) { + return@update state + } + val target = state.selectedDayIndex + offset + if (target in state.chartRange) { + state.copy(selectedDayIndex = target) + } else { + state + } + } + } +} + +sealed class PostStatsDetailUiState { + data object Loading : PostStatsDetailUiState() + + data class Loaded( + val data: PostViewsData, + /** Index into [PostViewsData.dailyViews]; -1 when the post has no history. */ + val selectedDayIndex: Int + ) : PostStatsDetailUiState() { + /** + * The days the chart draws: a fixed trailing window, so stepping through days moves the + * highlight within a stable set of bars rather than sliding the whole chart. This is the + * framing the old stats screen uses, and it bounds how far back the arrows can go. + */ + val chartDays: List = + data.dailyViews.subList( + (data.dailyViews.size - CHART_DAYS) + .coerceAtLeast(0), + data.dailyViews.size + ) + + /** Indices of [PostViewsData.dailyViews] the chart covers, i.e. what the arrows can reach. */ + val chartRange: IntRange = + data.dailyViews.size - chartDays.size until + data.dailyViews.size + + /** Position of the selected day within [chartDays]. */ + val selectedChartIndex: Int = + selectedDayIndex - chartRange.first + + val selectedDay: PostViewsDailyView? = + data.dailyViews.getOrNull(selectedDayIndex) + + /** The day before the selected one, which the change is measured against. */ + val previousDay: PostViewsDailyView? = + data.dailyViews.getOrNull(selectedDayIndex - 1) + + val hasNextDay: Boolean = + selectedDayIndex < chartRange.last + } + + data class Error( + val message: String + ) : PostStatsDetailUiState() +} + +// Two weeks of bars, matching the old stats post detail chart. +private const val CHART_DAYS = 14 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt new file mode 100644 index 000000000000..7dac2969bcec --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsLatestPostUseCase.kt @@ -0,0 +1,75 @@ +package org.wordpress.android.ui.newstats.repository + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.newstats.datasource.LatestPostDataSource +import org.wordpress.android.ui.newstats.datasource.LatestPostLookupResult +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import javax.inject.Inject + +/** + * Loads the site's newest published post together with its view stats. + * + * Two calls: the post ID comes from the site's REST API, the stats from WP.com. There is no + * caching here -- [org.wordpress.android.ui.newstats.latestpost.LatestPostViewModel] is the only + * consumer and already tracks whether it has loaded. + */ +class StatsLatestPostUseCase @Inject constructor( + private val statsRepository: StatsRepository, + private val latestPostDataSource: LatestPostDataSource, + private val accountStore: AccountStore +) { + suspend operator fun invoke( + site: SiteModel + ): LatestPostResult { + val token = accountStore.accessToken + if (token.isNullOrEmpty()) { + return LatestPostResult.Error("No access token") + } + statsRepository.init(token) + + return when ( + val lookup = latestPostDataSource + .fetchLatestPublishedPost(site) + ) { + is LatestPostLookupResult.NoPosts -> + LatestPostResult.NoPosts + is LatestPostLookupResult.Error -> + LatestPostResult.Error(lookup.message) + is LatestPostLookupResult.Success -> + when ( + val views = statsRepository + .fetchPostViews( + siteId = site.siteId, + postId = lookup.postId + ) + ) { + is PostViewsResult.Success -> + LatestPostResult.Success( + data = views.data, + featuredImageUrl = + lookup.featuredImageUrl + ) + is PostViewsResult.Error -> + LatestPostResult.Error(views.message) + } + } + } +} + +/** + * Result of loading the latest post's stats. [NoPosts] means the site has nothing published yet. + */ +sealed class LatestPostResult { + data class Success( + val data: PostViewsData, + /** Null when the post has no featured image. */ + val featuredImageUrl: String? + ) : LatestPostResult() + + data object NoPosts : LatestPostResult() + + data class Error( + val message: String + ) : LatestPostResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 58f8144ceaa8..d3b0f037ba8a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -9,6 +9,8 @@ import org.wordpress.android.ui.newstats.datasource.ClicksDataResult import org.wordpress.android.ui.newstats.datasource.CountryViewsDataResult import org.wordpress.android.ui.newstats.datasource.DevicesDataResult import org.wordpress.android.ui.newstats.datasource.FileDownloadsDataResult +import org.wordpress.android.ui.newstats.datasource.PostViewsData +import org.wordpress.android.ui.newstats.datasource.PostViewsDataResult import org.wordpress.android.ui.newstats.datasource.ReferrersDataResult import org.wordpress.android.ui.newstats.datasource.RegionViewsDataResult import org.wordpress.android.ui.newstats.datasource.SearchTermsDataResult @@ -954,8 +956,7 @@ class StatsRepository @Inject constructor( views = item.views, previousViews = previousViews, isFirst = index == 0, - url = item.url, - postType = item.postType + url = item.url ) }, totalViews = totalViews, @@ -1847,6 +1848,35 @@ class StatsRepository @Inject constructor( } } + /** + * Fetches view stats for a single post, or for the site's home page when [postId] is 0. + */ + suspend fun fetchPostViews( + siteId: Long, + postId: Long + ): PostViewsResult = withContext(ioDispatcher) { + val result = statsDataSource.fetchPostViews( + siteId = siteId, + postId = postId + ) + when (result) { + is PostViewsDataResult.Success -> + PostViewsResult.Success( + data = result.data + ) + is PostViewsDataResult.Error -> { + appLogWrapper.e( + AppLog.T.STATS, + "Error fetching post views: " + + "${result.errorType}" + ) + PostViewsResult.Error( + result.errorType.name + ) + } + } + } + /** * Fetches all-time subscriber counts: current, 30d ago, * 60d ago, 90d ago. Makes 4 parallel API calls. @@ -2241,8 +2271,7 @@ data class MostViewedItemData( val previousViews: Long, val isFirst: Boolean, val children: List = emptyList(), - val url: String? = null, - val postType: String? = null + val url: String? = null ) { val viewsChange: Long get() = views - previousViews val viewsChangePercent: Double @@ -2543,6 +2572,18 @@ sealed class TagsResult { ) : TagsResult() } +/** + * Result of fetching a single post's view stats. + */ +sealed class PostViewsResult { + data class Success( + val data: PostViewsData + ) : PostViewsResult() + data class Error( + val message: String + ) : PostViewsResult() +} + /** * Result wrapper for subscribers all-time stats fetch operation. */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index 17c38e036fb8..8f76c9caa237 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -2,27 +2,100 @@ package org.wordpress.android.ui.newstats.util import org.wordpress.android.R import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider +import java.text.NumberFormat +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import java.util.Locale -private const val THOUSAND = 1_000 +const val THOUSAND = 1_000 private const val MILLION = 1_000_000 private const val FORMAT_MILLION = "%.1fM" private const val FORMAT_THOUSAND = "%.1fK" private const val MONTH_ABBREVIATION_LENGTH = 3 +private const val DISPLAY_DATE_PATTERN = "MMM d, yyyy" +private val API_DATE_TIME_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss", Locale.US) + /** - * Formats a stat value for display, using K/M suffixes for large numbers. - * Examples: 1500 -> "1.5K", 2500000 -> "2.5M", 500 -> "500" + * Formats an API timestamp ("yyyy-MM-dd HH:mm:ss", site timezone) for display, e.g. "Aug 4, 2026". + * Returns the input unchanged if it can't be parsed. */ -fun formatStatValue(value: Long): String { - return when { - value >= MILLION -> String.format(Locale.getDefault(), FORMAT_MILLION, value / MILLION.toDouble()) - value >= THOUSAND -> String.format(Locale.getDefault(), FORMAT_THOUSAND, value / THOUSAND.toDouble()) - else -> value.toString() - } +fun formatStatsDateTime(dateTime: String): String = parseOrLog(dateTime) { + LocalDateTime.parse(it, API_DATE_TIME_FORMAT) + .format(displayDateFormat()) +} + +/** + * Formats an API day ("yyyy-MM-dd") for display, e.g. "Aug 4, 2026". Returns the input unchanged + * if it can't be parsed. + */ +fun formatStatsDate(date: String): String = parseOrLog(date) { + LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) + .format(displayDateFormat()) +} + +private fun displayDateFormat() = DateTimeFormatter.ofPattern( + DISPLAY_DATE_PATTERN, + Locale.getDefault() +) + +@Suppress("TooGenericExceptionCaught") +private inline fun parseOrLog( + value: String, + format: (String) -> String +): String = try { + format(value) +} catch (e: Exception) { + AppLog.w( + AppLog.T.STATS, + "Failed to parse stats date '$value': ${e.message}" + ) + value } +/** + * Formats a stat value for display, grouped in full below [abbreviateFrom] and with a K/M suffix + * at or above it. Examples at the default threshold: 500 -> "500", 1500 -> "1.5K", + * 2500000 -> "2.5M"; at [TEN_THOUSAND]: 1986 -> "1,986". + * + * Cards are width-constrained and abbreviate early; detail screens pass [TEN_THOUSAND] so figures + * stay exact for longer, which is the threshold the old stats screens use. + */ +fun formatStatValue( + value: Long, + abbreviateFrom: Int = THOUSAND +): String = when { + value >= MILLION -> String.format( + Locale.getDefault(), + FORMAT_MILLION, + value / MILLION.toDouble() + ) + value >= abbreviateFrom.coerceAtLeast(THOUSAND) -> + String.format( + Locale.getDefault(), + FORMAT_THOUSAND, + value / THOUSAND.toDouble() + ) + else -> NumberFormat + .getIntegerInstance(Locale.getDefault()) + .format(value) +} + +/** Threshold for detail screens, which favour exact figures over width. */ +const val TEN_THOUSAND = 10_000 + +/** + * Formats a fractional change as a percentage, e.g. -0.25 -> "-25%". + */ +fun formatChangePercentage(fraction: Double): String = + NumberFormat.getPercentInstance(Locale.getDefault()) + .apply { maximumFractionDigits = 0 } + .format(fraction) + private const val FORMAT_DECIMAL = "%.1f" fun formatStatValue(value: Double): String { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 945176bfb3cd..a7130bf44bdd 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1629,6 +1629,9 @@ Avg. Views Per Day Recent Weeks Top Commentators + Post Stats + Previous day + Next day Posts and Pages Referrers diff --git a/WordPress/src/test/java/org/wordpress/android/ui/StatsDetailItemTypeTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/StatsDetailItemTypeTest.kt deleted file mode 100644 index 8e2f22717f98..000000000000 --- a/WordPress/src/test/java/org/wordpress/android/ui/StatsDetailItemTypeTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.wordpress.android.ui - -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test -import org.wordpress.android.ui.stats.StatsConstants - -class StatsDetailItemTypeTest { - @Test - fun `post type maps to post detail item type`() { - assertThat(statsDetailItemType("post")).isEqualTo(StatsConstants.ITEM_TYPE_POST) - } - - @Test - fun `attachment type maps to attachment detail item type`() { - assertThat(statsDetailItemType("attachment")).isEqualTo(StatsConstants.ITEM_TYPE_ATTACHMENT) - } - - @Test - fun `page, homepage, unknown and null types map to home page detail item type`() { - assertThat(statsDetailItemType("page")).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - assertThat(statsDetailItemType("homepage")).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - assertThat(statsDetailItemType("other")).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - assertThat(statsDetailItemType(null)).isEqualTo(StatsConstants.ITEM_TYPE_HOME_PAGE) - } -} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt index 5c408b9cd96e..7085f81e0270 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt @@ -19,12 +19,8 @@ class InsightsCardsConfigurationTest { ) assertThat(config.hiddenCards) - .containsExactlyInAnyOrder( - InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME, - InsightsCardType.YEAR_IN_REVIEW, - InsightsCardType.TAGS_AND_CATEGORIES + .containsExactlyInAnyOrderElementsOf( + InsightsCardType.entries ) } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt index 506115726ba6..a3a820ea5f04 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt @@ -191,7 +191,7 @@ class MostViewedViewModelTest : BaseUnitTest() { } @Test - fun `when data loads, then url and post type are passed to card and detail items`() = test { + fun `when data loads, then url is passed to card and detail items`() = test { whenever(statsRepository.fetchMostViewed(any(), any(), any())) .thenReturn(createSuccessResult()) whenever(resourceProvider.getString(R.string.stats_period_last_7_days)) @@ -202,11 +202,9 @@ class MostViewedViewModelTest : BaseUnitTest() { val state = viewModel.postsUiState.value as MostViewedCardUiState.Loaded assertThat(state.items[0].url).isEqualTo(TEST_POST_URL_1) - assertThat(state.items[0].postType).isEqualTo(TEST_POST_TYPE_1) val detailItems = viewModel.getPostsDetailData().items assertThat(detailItems[0].url).isEqualTo(TEST_POST_URL_1) - assertThat(detailItems[0].postType).isEqualTo(TEST_POST_TYPE_1) } @Test @@ -681,8 +679,7 @@ class MostViewedViewModelTest : BaseUnitTest() { views = TEST_POST_VIEWS_1, previousViews = TEST_POST_PREVIOUS_VIEWS_1, isFirst = true, - url = TEST_POST_URL_1, - postType = TEST_POST_TYPE_1 + url = TEST_POST_URL_1 ), MostViewedItemData( id = 2, @@ -707,7 +704,6 @@ class MostViewedViewModelTest : BaseUnitTest() { private const val TEST_POST_TITLE_1 = "Test Post 1" private const val TEST_POST_TITLE_2 = "Test Post 2" private const val TEST_POST_URL_1 = "https://example.com/test-post-1" - private const val TEST_POST_TYPE_1 = "post" private const val TEST_POST_VIEWS_1 = 500L private const val TEST_POST_VIEWS_2 = 300L private const val TEST_REFERRER_URL = "https://google.com/" diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt index 5ba6a74cd589..e39375162e74 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt @@ -89,12 +89,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.getConfiguration(TEST_SITE_ID) assertThat(config.visibleCards) - .containsExactly( - InsightsCardType.YEAR_IN_REVIEW, - InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME, - InsightsCardType.TAGS_AND_CATEGORIES + .containsExactlyInAnyOrderElementsOf( + InsightsCardType.entries ) verify(appPrefsWrapper) .setStatsInsightsCardsConfigurationJson( @@ -105,17 +101,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when saved config has all card types, then no update is saved`() = test { - val json = """ - { - "visibleCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val json = ALL_CARDS_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -127,12 +113,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.getConfiguration(TEST_SITE_ID) assertThat(config.visibleCards) - .containsExactly( - InsightsCardType.YEAR_IN_REVIEW, - InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME, - InsightsCardType.TAGS_AND_CATEGORIES + .containsExactlyInAnyOrderElementsOf( + InsightsCardType.entries ) verify( appPrefsWrapper, @@ -163,18 +145,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when addCard is called on empty config, then json is saved to prefs`() = test { - val emptyJson = """ - { - "visibleCards": [], - "hiddenCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val emptyJson = ALL_HIDDEN_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -267,18 +238,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when addCard is called, then card is added to visible cards`() = test { - val initialJson = """ - { - "visibleCards": [], - "hiddenCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val initialJson = ALL_HIDDEN_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -304,18 +264,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when mutation occurs, then configurationFlow emits site id and configuration`() = test { - val json = """ - { - "visibleCards": [], - "hiddenCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + val json = ALL_HIDDEN_JSON whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( @@ -533,12 +482,11 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { jsonCaptor.firstValue, InsightsCardsConfiguration::class.java ) + val cards = InsightsCardType.entries assertThat(saved.visibleCards[1]) - .isEqualTo( - InsightsCardType.MOST_POPULAR_DAY - ) + .isEqualTo(cards[2]) assertThat(saved.visibleCards[2]) - .isEqualTo(InsightsCardType.ALL_TIME_STATS) + .isEqualTo(cards[1]) } @Test @@ -605,16 +553,20 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L - private val ALL_CARDS_JSON = """ - { - "visibleCards": [ - "YEAR_IN_REVIEW", - "ALL_TIME_STATS", - "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME", - "TAGS_AND_CATEGORIES" - ] - } - """.trimIndent() + // Derived from the enum so adding a card type can't leave this fixture stale -- + // loadAndMigrate() appends any missing type, which changes both the card list and + // whether a migration is persisted. + private val ALL_HIDDEN_JSON = InsightsCardType.entries + .joinToString( + separator = ",", + prefix = "{\"visibleCards\":[],\"hiddenCards\":[", + postfix = "]}" + ) { "\"${it.name}\"" } + private val ALL_CARDS_JSON = InsightsCardType.entries + .joinToString( + separator = ",", + prefix = "{\"visibleCards\":[", + postfix = "]}" + ) { "\"${it.name}\"" } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8bca16d1d798..9c04fac2b320 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '0.6.0' +wordpress-rs = '1489-ae229223a02c28bcb0b729b680201000634d8158' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3'