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 04d6d79ef287..e9c24172f624 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 @@ -64,6 +64,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.components.AddCardBottomSheet @@ -137,6 +138,9 @@ class NewStatsActivity : BaseAppCompatActivity() { @Inject lateinit var newStatsFeatureConfig: NewStatsFeatureConfig + @Inject + lateinit var activityNavigator: ActivityNavigator + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val shouldShowIntro = @@ -153,6 +157,9 @@ class NewStatsActivity : BaseAppCompatActivity() { onIntroDismissed = { appPrefsWrapper .setNewStatsIntroShown(true) + }, + onReferrerChildClick = { url -> + activityNavigator.openInCustomTab(this, url) } ) } @@ -228,7 +235,8 @@ private fun NewStatsScreen( showSwitchToOldStats: Boolean = false, onSwitchToOldStats: () -> Unit = {}, showIntroBottomSheet: Boolean = false, - onIntroDismissed: () -> Unit = {} + onIntroDismissed: () -> Unit = {}, + onReferrerChildClick: (String) -> Unit = {} ) { val viewsStatsViewModel: ViewsStatsViewModel = viewModel() val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState() @@ -370,7 +378,8 @@ private fun NewStatsScreen( ) { page -> StatsTabContent( tab = tabs[page], - viewsStatsViewModel = viewsStatsViewModel + viewsStatsViewModel = viewsStatsViewModel, + onReferrerChildClick = onReferrerChildClick ) } } @@ -380,11 +389,13 @@ private fun NewStatsScreen( @Composable private fun StatsTabContent( tab: StatsTab, - viewsStatsViewModel: ViewsStatsViewModel + viewsStatsViewModel: ViewsStatsViewModel, + onReferrerChildClick: (String) -> Unit = {} ) { when (tab) { StatsTab.TRAFFIC -> TrafficTabContent( - viewsStatsViewModel = viewsStatsViewModel + viewsStatsViewModel = viewsStatsViewModel, + onReferrerChildClick = onReferrerChildClick ) StatsTab.INSIGHTS -> InsightsTabContent() StatsTab.SUBSCRIBERS -> SubscribersTabContent() @@ -406,7 +417,8 @@ private fun TrafficTabContent( fileDownloadsViewModel: FileDownloadsViewModel = viewModel(), devicesViewModel: DevicesViewModel = viewModel(), utmViewModel: UtmViewModel = viewModel(), - newStatsViewModel: NewStatsViewModel = viewModel() + newStatsViewModel: NewStatsViewModel = viewModel(), + onReferrerChildClick: (String) -> Unit = {} ) { val context = LocalContext.current val todaysStatsUiState by todaysStatsViewModel.uiState.collectAsState() @@ -679,15 +691,11 @@ private fun TrafficTabContent( uiState = referrersUiState, cardType = cardType, onShowAllClick = { - val detailData = mostViewedViewModel.getReferrersDetailData() - MostViewedDetailActivity.start( + // The referrers detail screen self-fetches the full (unbounded) list, so + // only the selected period is passed instead of the whole item list. + MostViewedDetailActivity.startReferrers( context = context, - cardType = detailData.cardType, - items = detailData.items, - totalViews = detailData.totalViews, - totalViewsChange = detailData.totalViewsChange, - totalViewsChangePercent = detailData.totalViewsChangePercent, - dateRange = detailData.dateRange + period = mostViewedViewModel.getCurrentPeriod() ) }, onRetry = mostViewedViewModel::onRetryReferrers, @@ -696,7 +704,8 @@ private fun TrafficTabContent( onMoveUp = { newStatsViewModel.moveCardUp(cardType) }, onMoveToTop = { newStatsViewModel.moveCardToTop(cardType) }, onMoveDown = { newStatsViewModel.moveCardDown(cardType) }, - onMoveToBottom = { newStatsViewModel.moveCardToBottom(cardType) } + onMoveToBottom = { newStatsViewModel.moveCardToBottom(cardType) }, + onChildClick = onReferrerChildClick ) StatsCardType.LOCATIONS -> LocationsCard( uiState = locationsUiState, 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 c0c6cda7060c..8f3b415b531d 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 @@ -418,6 +418,16 @@ sealed class ReferrersDataResult { */ data class ReferrerDataItem( val name: String, + val views: Long, + val children: List = emptyList() +) + +/** + * A child referrer nested under a referrer group (e.g. a specific search engine). + */ +data class ReferrerChildDataItem( + val name: String, + val url: String?, val views: Long ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index d5f7c02a0489..bb56d064e5f5 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 @@ -14,6 +14,7 @@ import uniffi.wp_api.StatsFileDownloadsParams import uniffi.wp_api.StatsFileDownloadsPeriod import uniffi.wp_api.StatsReferrersParams import uniffi.wp_api.StatsReferrersPeriod +import uniffi.wp_api.StatsReferrersResults import uniffi.wp_api.StatsRegionViewsParams import uniffi.wp_api.StatsRegionViewsPeriod import uniffi.wp_api.StatsDevicesParams @@ -204,19 +205,22 @@ class StatsDataSourceImpl @Inject constructor( dateRange: StatsDateRange, max: Int ): ReferrersDataResult { + // Referrers-specific max semantics: the server treats max=0 as "all" but an unset max as + // "default 10", so 0 is sent explicitly (unlike other endpoints that map 0 -> null). + val maxParam = max.coerceAtLeast(0).toUInt() val params = when (dateRange) { is StatsDateRange.Preset -> StatsReferrersParams( period = StatsReferrersPeriod.DAY, date = dateRange.date, num = dateRange.num.toUInt(), - max = max.coerceAtLeast(1).toUInt(), + max = maxParam, locale = wpComLanguage ) is StatsDateRange.Custom -> StatsReferrersParams( period = StatsReferrersPeriod.DAY, date = dateRange.date, startDate = dateRange.startDate, - max = max.coerceAtLeast(1).toUInt(), + max = maxParam, locale = wpComLanguage ) } @@ -238,7 +242,8 @@ class StatsDataSourceImpl @Inject constructor( groups.map { group -> ReferrerDataItem( name = group.name.orEmpty(), - views = group.total?.toLong() ?: 0L + views = group.total?.toLong() ?: 0L, + children = group.results.toChildren() ) } ) @@ -249,6 +254,18 @@ class StatsDataSourceImpl @Inject constructor( } } + private fun StatsReferrersResults?.toChildren(): List = + when (this) { + is StatsReferrersResults.Referrers -> v1.map { child -> + ReferrerChildDataItem( + name = child.name.orEmpty(), + url = child.url, + views = child.views?.toLong() ?: 0L + ) + } + else -> emptyList() + } + private fun buildCountryViewsParams(dateRange: StatsDateRange, max: Int) = when (dateRange) { is StatsDateRange.Preset -> StatsCountryViewsParams( period = StatsCountryViewsPeriod.DAY, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt index 38312fd0d414..42f16b1f3682 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt @@ -5,29 +5,25 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight 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.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Button -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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 org.wordpress.android.R @@ -38,13 +34,11 @@ import org.wordpress.android.ui.newstats.components.CardPosition import org.wordpress.android.ui.newstats.components.ShowAllFooter import org.wordpress.android.ui.newstats.components.StatsCardMenu import org.wordpress.android.ui.newstats.util.ShimmerBox -import org.wordpress.android.ui.newstats.util.formatStatValue import java.util.Locale private val CardCornerRadius = 10.dp private val CardPadding = 16.dp private val CardMargin = 16.dp -internal const val HIGHLIGHTED_ITEM_BACKGROUND_ALPHA = 0.08f private const val LOADING_SHIMMER_ITEM_COUNT = 5 @Composable @@ -60,7 +54,8 @@ fun MostViewedCard( onMoveToTop: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, onMoveToBottom: (() -> Unit)? = null, - onOpenWpAdmin: (() -> Unit)? = null + onOpenWpAdmin: (() -> Unit)? = null, + onChildClick: (String) -> Unit = {} ) { val borderColor = MaterialTheme.colorScheme.outlineVariant @@ -80,7 +75,8 @@ fun MostViewedCard( is MostViewedCardUiState.Loading -> LoadingContent() is MostViewedCardUiState.Loaded -> LoadedContent( uiState, cardType, onShowAllClick, onRemoveCard, - cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom + cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom, + onChildClick ) is MostViewedCardUiState.Error -> ErrorContent( uiState, cardType, onRetry, onRemoveCard, @@ -173,7 +169,8 @@ private fun LoadedContent( onMoveUp: (() -> Unit)?, onMoveToTop: (() -> Unit)?, onMoveDown: (() -> Unit)?, - onMoveToBottom: (() -> Unit)? + onMoveToBottom: (() -> Unit)?, + onChildClick: (String) -> Unit ) { Column( modifier = Modifier @@ -199,7 +196,20 @@ private fun LoadedContent( val percentage = if (state.maxViewsForBar > 0) { item.views.toFloat() / state.maxViewsForBar.toFloat() } else 0f - MostViewedItemRow(item = item, percentage = percentage) + // Key on the index plus the item id: the index guarantees uniqueness (referrer ids + // are derived from name.hashCode() and can collide on empty/duplicate names), while + // the id resets the row's saved expanded state when a different referrer lands at + // this position after a period reload. rememberSaveable keeps it across rotation. + key(index, item.id) { + MostViewedExpandableRow( + title = item.title, + views = item.views, + change = item.change, + children = item.children, + percentage = percentage, + onChildClick = onChildClick + ) + } if (index < state.items.lastIndex) { Spacer(modifier = Modifier.height(4.dp)) } @@ -282,67 +292,6 @@ private fun ColumnHeadersRow(cardType: StatsCardType) { } } -@Composable -private fun MostViewedItemRow(item: MostViewedItem, percentage: Float) { - val barColor = MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHTED_ITEM_BACKGROUND_ALPHA) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(8.dp)) - ) { - // Background bar representing the percentage - Box( - modifier = Modifier - .fillMaxWidth(fraction = percentage) - .fillMaxHeight() - .background(barColor) - ) - - // Content - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = item.title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - Column(horizontalAlignment = Alignment.End) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = formatStatValue(item.views), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ChangeIndicator(change = item.change) - } - } - } - } -} - @Composable internal fun ChangeIndicator(change: MostViewedChange) { val (text, color) = when (change) { 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 e053da3de913..46f11d9f9132 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 @@ -42,9 +42,24 @@ data class MostViewedItem( val id: Long, val title: String, val views: Long, - val change: MostViewedChange + val change: MostViewedChange, + val children: List = emptyList() ) +/** + * A child item nested under a Most Viewed item (e.g. a referrer under a referrer group). + * + * @param name The name of the child item + * @param url The URL opened when the child row is tapped (null if not linkable) + * @param views The number of views + */ +@Parcelize +data class MostViewedChildItem( + val name: String, + val url: String?, + val views: Long +) : Parcelable + /** * Represents the change in views compared to the previous period. */ @@ -84,5 +99,6 @@ data class MostViewedDetailItem( val id: Long, val title: String, val views: Long, - val change: MostViewedChange + val change: MostViewedChange, + val children: List = emptyList() ) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCommonComposables.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCommonComposables.kt new file mode 100644 index 000000000000..0d8a67786e14 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCommonComposables.kt @@ -0,0 +1,207 @@ +package org.wordpress.android.ui.newstats.mostviewed + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.util.formatStatValue + +/** + * Expandable Most Viewed row (group title, views, change badge, and an optional expand/collapse + * chevron that reveals indented child rows). Shared by the card ([MostViewedCard]) and the detail + * screen ([MostViewedDetailActivity]); they differ only by the leading [position] number (detail + * only) and the child [childStartPadding] indent. + * + * The caller is expected to wrap this row in a `key(...)` so the [rememberSaveable] expanded state + * is scoped to the item and survives configuration changes (e.g. rotation). + * + * @param title The group title + * @param views The group's view count + * @param change The change badge shown under the views + * @param children The child rows revealed when expanded (empty = no chevron, not expandable) + * @param percentage Bar fill percentage (0f..1f) + * @param onChildClick Invoked with a child's URL when a linkable child row is tapped + * @param position Optional 1-based position number shown on the detail screen + * @param childStartPadding Indent applied to the expanded child rows + */ +@Composable +internal fun MostViewedExpandableRow( + title: String, + views: Long, + change: MostViewedChange, + children: List, + percentage: Float, + onChildClick: (String) -> Unit, + position: Int? = null, + childStartPadding: Dp = 24.dp +) { + val hasChildren = children.isNotEmpty() + var expanded by rememberSaveable { mutableStateOf(false) } + val clickModifier = if (hasChildren) Modifier.clickable { expanded = !expanded } else Modifier + + Column { + StatsListRowContainer(percentage = percentage, modifier = clickModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (position != null) { + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(32.dp) + ) + } + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(horizontalAlignment = Alignment.End) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (hasChildren) { + MostViewedExpandChevron(expanded = expanded) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = formatStatValue(views), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + ChangeIndicator(change = change) + } + } + } + + MostViewedExpandableChildren( + children = children, + expanded = expanded, + startPadding = childStartPadding, + onChildClick = onChildClick + ) + } +} + +/** + * Expand/collapse chevron shown on a Most Viewed row that has children. + * Shared by the card ([MostViewedCard]) and the detail screen ([MostViewedDetailActivity]). + */ +@Composable +internal fun MostViewedExpandChevron(expanded: Boolean) { + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = stringResource( + if (expanded) R.string.stats_collapse_group else R.string.stats_expand_group + ), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +/** + * Indented, animated list of child rows shown when a Most Viewed group is expanded. + * Shared by the card and the detail screen; only the [startPadding] indent differs. + */ +@Composable +internal fun MostViewedExpandableChildren( + children: List, + expanded: Boolean, + startPadding: Dp, + onChildClick: (String) -> Unit +) { + AnimatedVisibility(visible = expanded) { + val maxChildViews = children.maxOfOrNull { it.views } ?: 0L + Column( + modifier = Modifier.padding(start = startPadding, top = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + children.forEach { child -> + val childPercentage = if (maxChildViews > 0) { + child.views.toFloat() / maxChildViews.toFloat() + } else 0f + MostViewedChildRow(child = child, percentage = childPercentage, onChildClick = onChildClick) + } + } + } +} + +@Composable +private fun MostViewedChildRow( + child: MostViewedChildItem, + percentage: Float, + onChildClick: (String) -> Unit +) { + val url = child.url + val isClickable = !url.isNullOrBlank() + val clickModifier = if (isClickable) Modifier.clickable { onChildClick(url) } else Modifier + + StatsListRowContainer(percentage = percentage, modifier = clickModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = child.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formatStatValue(child.views), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (isClickable) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.stats_open_referrer_link), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} 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 ea51e19f7216..f38e59098cca 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 @@ -4,26 +4,22 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.compose.foundation.background +import androidx.activity.viewModels import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,23 +28,26 @@ 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.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip 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 dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R +import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.StatsCardType +import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.components.StatsSummaryCard -import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.util.extensions.getParcelableArrayListCompat import org.wordpress.android.util.extensions.getSerializableCompat +import javax.inject.Inject private const val EXTRA_CARD_TYPE = "extra_card_type" private const val EXTRA_ITEMS = "extra_items" @@ -58,42 +57,80 @@ private const val EXTRA_TOTAL_VIEWS_CHANGE_PERCENT = "extra_total_views_change_p private const val EXTRA_DATE_RANGE = "extra_date_range" private const val EXTRA_VALUE_HEADER_RES_ID = "extra_value_header_res_id" +// Self-fetch mode (referrers): the detail screen re-fetches its own unbounded data for this period +// instead of receiving the item list through the Intent. When these are absent the screen renders +// the items passed via EXTRA_ITEMS (posts, clicks, search terms, etc.). +private const val EXTRA_PERIOD_TYPE = "extra_period_type" +private const val EXTRA_PERIOD_CUSTOM_START = "extra_period_custom_start" +private const val EXTRA_PERIOD_CUSTOM_END = "extra_period_custom_end" +private const val NO_EPOCH_DAY = -1L + @AndroidEntryPoint class MostViewedDetailActivity : BaseAppCompatActivity() { + @Inject lateinit var activityNavigator: ActivityNavigator + + private val viewModel: MostViewedDetailViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val cardType = intent.extras?.getSerializableCompat(EXTRA_CARD_TYPE) ?: StatsCardType.MOST_VIEWED_POSTS_AND_PAGES - val items = intent.extras?.getParcelableArrayListCompat(EXTRA_ITEMS) - ?: arrayListOf() - val totalViews = intent.getLongExtra(EXTRA_TOTAL_VIEWS, 0L) - val totalViewsChange = intent.getLongExtra(EXTRA_TOTAL_VIEWS_CHANGE, 0L) - val totalViewsChangePercent = intent.getDoubleExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, 0.0) - val dateRange = intent.getStringExtra(EXTRA_DATE_RANGE) ?: "" - val valueHeaderResId = intent.getIntExtra( - EXTRA_VALUE_HEADER_RES_ID, R.string.stats_views - ) - // Calculate maxViewsForBar once (list is sorted by views descending) - val maxViewsForBar = items.firstOrNull()?.views ?: 1L + val valueHeaderResId = intent.getIntExtra(EXTRA_VALUE_HEADER_RES_ID, R.string.stats_views) + val selfFetchPeriod = readSelfFetchPeriod() + + if (selfFetchPeriod != null) { + viewModel.loadReferrers(selfFetchPeriod) + } setContent { AppThemeM3 { + val uiState = if (selfFetchPeriod != null) { + viewModel.uiState.collectAsState().value + } else { + remember { intent.toPassedItemsState() } + } MostViewedDetailScreen( cardType = cardType, - items = items, - maxViewsForBar = maxViewsForBar, - totalViews = totalViews, - totalViewsChange = totalViewsChange, - totalViewsChangePercent = totalViewsChangePercent, - dateRange = dateRange, + uiState = uiState, valueHeaderResId = valueHeaderResId, - onBackPressed = onBackPressedDispatcher::onBackPressed + onBackPressed = onBackPressedDispatcher::onBackPressed, + onRetry = { viewModel.retry() }, + onChildClick = { url -> activityNavigator.openInCustomTab(this, url) } ) } } } + /** + * Reads the period for self-fetch mode, or null when the screen should render the items passed + * via the Intent (posts, clicks, search terms, etc.). + */ + private fun readSelfFetchPeriod(): StatsPeriod? { + val periodType = intent.getStringExtra(EXTRA_PERIOD_TYPE) ?: return null + val customStart = intent.getLongExtra(EXTRA_PERIOD_CUSTOM_START, NO_EPOCH_DAY) + val customEnd = intent.getLongExtra(EXTRA_PERIOD_CUSTOM_END, NO_EPOCH_DAY) + return StatsPeriod.fromTypeString( + type = periodType, + customStartEpochDay = customStart.takeIf { it != NO_EPOCH_DAY }, + customEndEpochDay = customEnd.takeIf { it != NO_EPOCH_DAY } + ) + } + + private fun Intent.toPassedItemsState(): MostViewedDetailUiState.Loaded { + val items = extras?.getParcelableArrayListCompat(EXTRA_ITEMS) + ?: arrayListOf() + return MostViewedDetailUiState.Loaded( + items = items, + // Calculate maxViewsForBar once (list is sorted by views descending) + maxViewsForBar = items.firstOrNull()?.views ?: 1L, + totalViews = getLongExtra(EXTRA_TOTAL_VIEWS, 0L), + totalViewsChange = getLongExtra(EXTRA_TOTAL_VIEWS_CHANGE, 0L), + totalViewsChangePercent = getDoubleExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, 0.0), + dateRange = getStringExtra(EXTRA_DATE_RANGE) ?: "" + ) + } + companion object { @Suppress("LongParameterList") fun start( @@ -122,6 +159,22 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { } context.startActivity(intent) } + + /** + * Launches the referrers detail screen in self-fetch mode: only the [period] is passed and + * the screen re-fetches the full referrer list itself (see [MostViewedDetailViewModel]). + */ + fun startReferrers(context: Context, period: StatsPeriod) { + val intent = Intent(context, MostViewedDetailActivity::class.java).apply { + putExtra(EXTRA_CARD_TYPE, StatsCardType.MOST_VIEWED_REFERRERS) + putExtra(EXTRA_PERIOD_TYPE, period.toTypeString()) + if (period is StatsPeriod.Custom) { + putExtra(EXTRA_PERIOD_CUSTOM_START, period.startDate.toEpochDay()) + putExtra(EXTRA_PERIOD_CUSTOM_END, period.endDate.toEpochDay()) + } + } + context.startActivity(intent) + } } } @@ -129,14 +182,11 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { @Composable private fun MostViewedDetailScreen( cardType: StatsCardType, - items: List, - maxViewsForBar: Long, - totalViews: Long, - totalViewsChange: Long, - totalViewsChangePercent: Double, - dateRange: String, + uiState: MostViewedDetailUiState, valueHeaderResId: Int = R.string.stats_views, - onBackPressed: () -> Unit + onBackPressed: () -> Unit, + onRetry: () -> Unit = {}, + onChildClick: (String) -> Unit = {} ) { val title = stringResource(cardType.displayNameResId) @@ -155,46 +205,106 @@ private fun MostViewedDetailScreen( ) } ) { contentPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .padding(horizontal = 16.dp) - ) { - item { - Spacer(modifier = Modifier.height(8.dp)) - StatsSummaryCard( - totalViews = totalViews, - dateRange = dateRange, - totalViewsChange = totalViewsChange, - totalViewsChangePercent = totalViewsChangePercent - ) - Spacer(modifier = Modifier.height(16.dp)) - } + val contentModifier = Modifier + .fillMaxSize() + .padding(contentPadding) + when (uiState) { + is MostViewedDetailUiState.Loading -> DetailLoadingContent(contentModifier) + is MostViewedDetailUiState.Error -> DetailErrorContent(uiState.message, onRetry, contentModifier) + is MostViewedDetailUiState.Loaded -> DetailLoadedContent( + state = uiState, + valueHeaderResId = valueHeaderResId, + onChildClick = onChildClick, + modifier = contentModifier + ) + } + } +} - item { - ColumnHeaders( - itemCount = items.size, - valueHeaderResId = valueHeaderResId - ) - Spacer(modifier = Modifier.height(8.dp)) - } +@Composable +private fun DetailLoadedContent( + state: MostViewedDetailUiState.Loaded, + valueHeaderResId: Int, + onChildClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp) + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + StatsSummaryCard( + totalViews = state.totalViews, + dateRange = state.dateRange, + totalViewsChange = state.totalViewsChange, + totalViewsChangePercent = state.totalViewsChangePercent + ) + Spacer(modifier = Modifier.height(16.dp)) + } - itemsIndexed(items) { index, item -> - DetailItemRow( - position = index + 1, - item = item, - maxViewsForBar = maxViewsForBar - ) - if (index < items.lastIndex) { - Spacer(modifier = Modifier.height(4.dp)) - } - } + item { + ColumnHeaders( + itemCount = state.items.size, + valueHeaderResId = valueHeaderResId + ) + Spacer(modifier = Modifier.height(8.dp)) + } - item { - Spacer(modifier = Modifier.height(16.dp)) + // Key by index rather than item.id: referrer ids are derived from name.hashCode(), + // which is not guaranteed unique (empty/duplicate names collide) and would crash the + // LazyColumn. The list is a static snapshot, so the index is a stable, unique key. + itemsIndexed(state.items, key = { index, _ -> index }) { index, item -> + val percentage = if (state.maxViewsForBar > 0) { + item.views.toFloat() / state.maxViewsForBar.toFloat() + } else 0f + MostViewedExpandableRow( + title = item.title, + views = item.views, + change = item.change, + children = item.children, + percentage = percentage, + onChildClick = onChildClick, + position = index + 1, + childStartPadding = 32.dp + ) + if (index < state.items.lastIndex) { + Spacer(modifier = Modifier.height(4.dp)) } } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun DetailLoadingContent(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun DetailErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } } } @@ -222,98 +332,31 @@ private fun ColumnHeaders( } } -@Composable -private fun DetailItemRow( - position: Int, - item: MostViewedDetailItem, - maxViewsForBar: Long -) { - val percentage = if (maxViewsForBar > 0) item.views.toFloat() / maxViewsForBar.toFloat() else 0f - val barColor = MaterialTheme.colorScheme.primary.copy( - alpha = HIGHLIGHTED_ITEM_BACKGROUND_ALPHA - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(8.dp)) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction = percentage) - .fillMaxHeight() - .background(barColor) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = position.toString(), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(32.dp) - ) - - Text( - text = item.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Column(horizontalAlignment = Alignment.End) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = formatStatValue(item.views), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ChangeIndicator(change = item.change) - } - } - } -} - @Preview(showBackground = true) @Composable private fun MostViewedDetailScreenPreview() { AppThemeM3 { MostViewedDetailScreen( cardType = StatsCardType.MOST_VIEWED_POSTS_AND_PAGES, - items = listOf( - MostViewedDetailItem(1, "Welcome to Automattic", 998, - MostViewedChange.Positive(41, 4.3)), - MostViewedDetailItem(2, "Travel Guidelines", 111, - MostViewedChange.Positive(22, 24.7)), - MostViewedDetailItem(3, "LibreChat", 93, - MostViewedChange.Positive(21, 29.2)), - MostViewedDetailItem(4, "Getting Started with Claude Code: A Comprehensive Tutorial", 91, - MostViewedChange.Positive(47, 106.8)), - MostViewedDetailItem(5, "AI Tools & Resource Hub", 72, - MostViewedChange.Positive(31, 75.6)) + uiState = MostViewedDetailUiState.Loaded( + items = listOf( + MostViewedDetailItem(1, "Welcome to Automattic", 998, + MostViewedChange.Positive(41, 4.3)), + MostViewedDetailItem(2, "Travel Guidelines", 111, + MostViewedChange.Positive(22, 24.7)), + MostViewedDetailItem(3, "LibreChat", 93, + MostViewedChange.Positive(21, 29.2)), + MostViewedDetailItem(4, "Getting Started with Claude Code: A Comprehensive Tutorial", 91, + MostViewedChange.Positive(47, 106.8)), + MostViewedDetailItem(5, "AI Tools & Resource Hub", 72, + MostViewedChange.Positive(31, 75.6)) + ), + maxViewsForBar = 998, + totalViews = 5400, + totalViewsChange = 69, + totalViewsChangePercent = 1.3, + dateRange = "21-27 Jan" ), - maxViewsForBar = 998, - totalViews = 5400, - totalViewsChange = 69, - totalViewsChangePercent = 1.3, - dateRange = "21-27 Jan", onBackPressed = {} ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailViewModel.kt new file mode 100644 index 000000000000..ca12429c3428 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailViewModel.kt @@ -0,0 +1,112 @@ +package org.wordpress.android.ui.newstats.mostviewed + +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.CancellationException +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.ui.newstats.repository.MostViewedResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.util.toDateRangeString +import org.wordpress.android.util.AppLog +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +/** + * Backs [MostViewedDetailActivity] when it self-fetches its data (currently the referrers detail + * screen). The referrers card is bounded to [StatsRepository]'s card max, so the detail screen + * re-requests the full, unbounded list (max = 0) instead of receiving it through the Intent — this + * keeps the card request small and avoids passing a large parcelable list across the process + * boundary (which could exceed the Binder transaction limit). + */ +@HiltViewModel +class MostViewedDetailViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = MutableStateFlow(MostViewedDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private var period: StatsPeriod? = null + private var hasStartedLoading = false + + /** + * Loads the referrers detail data for [period]. Safe to call on every [MostViewedDetailActivity] + * creation: the fetch only runs once (subsequent calls after e.g. a rotation are ignored while a + * result is already present). Use [retry] to force a reload. + */ + fun loadReferrers(period: StatsPeriod) { + this.period = period + if (hasStartedLoading) return + load() + } + + fun retry() = load() + + private fun load() { + val period = period ?: return + val site = selectedSiteRepository.getSelectedSite() + val accessToken = accountStore.accessToken + if (site == null || accessToken.isNullOrEmpty()) { + _uiState.value = MostViewedDetailUiState.Error(resourceProvider.getString(R.string.stats_error_api)) + return + } + hasStartedLoading = true + statsRepository.init(accessToken) + _uiState.value = MostViewedDetailUiState.Loading + viewModelScope.launch { + _uiState.value = fetchReferrers(site.siteId, period) + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun fetchReferrers(siteId: Long, period: StatsPeriod): MostViewedDetailUiState = + try { + val result = statsRepository.fetchReferrersDetail(siteId = siteId, period = period) + when (result) { + is MostViewedResult.Success -> { + val items = result.items.map { it.toDetailItem() } + MostViewedDetailUiState.Loaded( + items = items, + maxViewsForBar = items.firstOrNull()?.views ?: 1L, + totalViews = result.totalViews, + totalViewsChange = result.totalViewsChange, + totalViewsChangePercent = result.totalViewsChangePercent, + dateRange = period.toDateRangeString(resourceProvider) + ) + } + is MostViewedResult.Error -> MostViewedDetailUiState.Error( + resourceProvider.getString(R.string.stats_error_api) + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(AppLog.T.STATS, "Error fetching referrers detail: ${e.message}", e) + MostViewedDetailUiState.Error(resourceProvider.getString(R.string.stats_error_unknown)) + } +} + +sealed interface MostViewedDetailUiState { + data object Loading : MostViewedDetailUiState + + data class Loaded( + val items: List, + val maxViewsForBar: Long, + val totalViews: Long, + val totalViewsChange: Long, + val totalViewsChangePercent: Double, + val dateRange: String + ) : MostViewedDetailUiState + + data class Error(val message: String) : MostViewedDetailUiState +} 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 a87ddb02f60d..97698a9d1d54 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 @@ -53,11 +53,6 @@ class MostViewedViewModel @Inject constructor( private var postsCachedTotalViewsChange: Long = 0L private var postsCachedTotalViewsChangePercent: Double = 0.0 - private var referrersAllItems: List = emptyList() - private var referrersCachedTotalViews: Long = 0L - private var referrersCachedTotalViewsChange: Long = 0L - private var referrersCachedTotalViewsChangePercent: Double = 0.0 - fun onPeriodChanged(period: StatsPeriod) { currentPeriod = period onPeriodChangedPosts(period) @@ -144,16 +139,11 @@ class MostViewedViewModel @Inject constructor( ) } - fun getReferrersDetailData(): MostViewedDetailData { - return MostViewedDetailData( - cardType = StatsCardType.MOST_VIEWED_REFERRERS, - items = referrersAllItems, - totalViews = referrersCachedTotalViews, - totalViewsChange = referrersCachedTotalViewsChange, - totalViewsChangePercent = referrersCachedTotalViewsChangePercent, - dateRange = currentPeriod.toDateRangeString(resourceProvider) - ) - } + /** + * The period currently selected for the referrers card. The referrers detail screen re-fetches + * its own (unbounded) data for this period rather than receiving the full list via the Intent. + */ + fun getCurrentPeriod(): StatsPeriod = currentPeriod fun loadData() { loadPosts() @@ -236,7 +226,8 @@ class MostViewedViewModel @Inject constructor( id = item.id, title = item.title, views = item.views, - change = item.change + change = item.change, + children = item.children ) }, maxViewsForBar = cardItems.firstOrNull()?.views ?: 1L @@ -259,10 +250,8 @@ class MostViewedViewModel @Inject constructor( _postsUiState.value = loadedState } MostViewedDataSource.REFERRERS -> { - referrersAllItems = allItems - referrersCachedTotalViews = result.totalViews - referrersCachedTotalViewsChange = result.totalViewsChange - referrersCachedTotalViewsChangePercent = result.totalViewsChangePercent + // The referrers detail screen self-fetches (see MostViewedDetailViewModel), so unlike + // posts we don't cache the full item list / totals here. _referrersUiState.value = loadedState } } @@ -336,11 +325,19 @@ data class MostViewedDetailData( val dateRange: String ) -private fun MostViewedItemData.toDetailItem(): MostViewedDetailItem { +internal fun MostViewedItemData.toDetailItem(): MostViewedDetailItem { val change = when { viewsChange > 0 -> MostViewedChange.Positive(viewsChange, abs(viewsChangePercent)) viewsChange < 0 -> MostViewedChange.Negative(abs(viewsChange), abs(viewsChangePercent)) else -> MostViewedChange.NoChange } - return MostViewedDetailItem(id = id, title = title, views = views, change = change) + return MostViewedDetailItem( + id = id, + title = title, + views = views, + change = change, + children = children.map { child -> + MostViewedChildItem(name = child.name, url = child.url, views = child.views) + } + ) } 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 6cea8670b6cd..5cedee2d3fa1 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 @@ -81,6 +81,11 @@ internal fun calculateItemChangePercent( private const val NUM_DAYS_TODAY = 1 private const val SUBSCRIBERS_DEFAULT_MAX = 10 +// Number of referrers requested for the card. The detail screen requests all of them (max = 0, +// which the server treats as "unlimited"). +private const val REFERRERS_CARD_MAX = 10 +private const val REFERRERS_DETAIL_MAX = 0 + /** * Repository for fetching stats data using the wordpress-rs API. * Handles hourly visits/views data for the Today's Stats card chart. @@ -590,11 +595,24 @@ class StatsRepository @Inject constructor( fetchTopPostsWithComparison(siteId, currentDateRange, previousDateRange) } MostViewedDataSource.REFERRERS -> { - fetchReferrersWithComparison(siteId, currentDateRange, previousDateRange) + fetchReferrersWithComparison(siteId, currentDateRange, previousDateRange, REFERRERS_CARD_MAX) } } } + /** + * Fetches the full referrer list (max = 0 = unlimited) for the referrers detail screen. Kept + * separate from [fetchMostViewed] (which bounds the card to [REFERRERS_CARD_MAX]) so the detail + * screen can show more entries than the card without inflating the card request. + */ + suspend fun fetchReferrersDetail( + siteId: Long, + period: StatsPeriod + ): MostViewedResult = withContext(ioDispatcher) { + val (currentDateRange, previousDateRange) = calculateComparisonDateRanges(period) + fetchReferrersWithComparison(siteId, currentDateRange, previousDateRange, REFERRERS_DETAIL_MAX) + } + private suspend fun fetchTopPostsWithComparison( siteId: Long, currentDateRange: StatsDateRange, @@ -660,10 +678,15 @@ class StatsRepository @Inject constructor( private suspend fun fetchReferrersWithComparison( siteId: Long, currentDateRange: StatsDateRange, - previousDateRange: StatsDateRange + previousDateRange: StatsDateRange, + max: Int ): MostViewedResult = coroutineScope { - val currentDeferred = async { statsDataSource.fetchReferrers(siteId, currentDateRange) } - val previousDeferred = async { statsDataSource.fetchReferrers(siteId, previousDateRange) } + // The card requests REFERRERS_CARD_MAX items; the detail screen requests all of them by + // passing max = 0 (the server treats 0 as "unlimited", vs. an unset max that defaults to 10). + // Fetching all only when the detail screen opens keeps the card request small and avoids + // passing a large list across the process boundary. + val currentDeferred = async { statsDataSource.fetchReferrers(siteId, currentDateRange, max = max) } + val previousDeferred = async { statsDataSource.fetchReferrers(siteId, previousDateRange, max = max) } val currentResult = currentDeferred.await() val previousResult = previousDeferred.await() @@ -690,7 +713,14 @@ class StatsRepository @Inject constructor( title = item.name, views = item.views, previousViews = previousViews, - isFirst = index == 0 + isFirst = index == 0, + children = item.children.map { child -> + MostViewedChildData( + name = child.name, + url = child.url, + views = child.views + ) + } ) }, totalViews = totalViews, @@ -1869,13 +1899,23 @@ data class MostViewedItemData( val title: String, val views: Long, val previousViews: Long, - val isFirst: Boolean + val isFirst: Boolean, + val children: List = emptyList() ) { val viewsChange: Long get() = views - previousViews val viewsChangePercent: Double get() = calculateItemChangePercent(views, previousViews) } +/** + * A child item nested under a most viewed item (e.g. a referrer under a referrer group). + */ +data class MostViewedChildData( + val name: String, + val url: String?, + val views: Long +) + /** * Result wrapper for country views fetch operation. */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCommonComposables.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCommonComposables.kt index 36977acee8b8..408528cba354 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCommonComposables.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCommonComposables.kt @@ -2,6 +2,7 @@ package org.wordpress.android.ui.newstats.utm import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -120,8 +121,10 @@ fun UtmExpandableRow( AnimatedVisibility(visible = expanded) { Column( modifier = Modifier.padding( - start = 24.dp - ) + start = 24.dp, + top = 4.dp + ), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { item.topPosts.forEach { post -> val postPct = diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index c200d82bd0a2..950741ce3bc9 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1586,6 +1586,7 @@ Tag Expand Collapse + Open link Check back when you\'ve published your first post! It\'s been %1$s since %2$s was published. Get the ball rolling and increase your post views by sharing your post: It’s been %1$s since %2$s was published. Here’s how the post performed so far: diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailViewModelTest.kt new file mode 100644 index 000000000000..532fab9e941e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailViewModelTest.kt @@ -0,0 +1,204 @@ +package org.wordpress.android.ui.newstats.mostviewed + +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.times +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.StatsPeriod +import org.wordpress.android.ui.newstats.repository.MostViewedChildData +import org.wordpress.android.ui.newstats.repository.MostViewedItemData +import org.wordpress.android.ui.newstats.repository.MostViewedResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class MostViewedDetailViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: MostViewedDetailViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) + whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) + whenever(resourceProvider.getString(R.string.stats_period_last_7_days)).thenReturn(DATE_RANGE) + whenever(resourceProvider.getString(R.string.stats_error_api)).thenReturn(API_ERROR) + whenever(resourceProvider.getString(R.string.stats_error_unknown)).thenReturn(UNKNOWN_ERROR) + viewModel = MostViewedDetailViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + } + + @Test + fun `when loadReferrers succeeds, then loaded state with items and totals is emitted`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(MostViewedDetailUiState.Loaded::class.java) + val loaded = state as MostViewedDetailUiState.Loaded + assertThat(loaded.items).hasSize(1) + assertThat(loaded.items[0].title).isEqualTo("Search Engines") + assertThat(loaded.maxViewsForBar).isEqualTo(TEST_VIEWS) + assertThat(loaded.totalViews).isEqualTo(TEST_TOTAL_VIEWS) + assertThat(loaded.totalViewsChange).isEqualTo(TEST_TOTAL_VIEWS_CHANGE) + assertThat(loaded.totalViewsChangePercent).isEqualTo(TEST_TOTAL_VIEWS_CHANGE_PERCENT) + assertThat(loaded.dateRange).isEqualTo(DATE_RANGE) + } + + @Test + fun `when loadReferrers succeeds, then child items propagate to the loaded state`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + + val loaded = viewModel.uiState.value as MostViewedDetailUiState.Loaded + assertThat(loaded.items[0].children).hasSize(1) + assertThat(loaded.items[0].children[0].name).isEqualTo("Google Search") + assertThat(loaded.items[0].children[0].url).isEqualTo(CHILD_URL) + assertThat(loaded.items[0].children[0].views).isEqualTo(TEST_VIEWS) + } + + @Test + fun `when no site is selected, then error state is emitted and no fetch happens`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(MostViewedDetailUiState.Error::class.java) + assertThat((state as MostViewedDetailUiState.Error).message).isEqualTo(API_ERROR) + verify(statsRepository, never()).fetchReferrersDetail(any(), any()) + } + + @Test + fun `when access token is empty, then error state is emitted and no fetch happens`() = test { + whenever(accountStore.accessToken).thenReturn("") + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(MostViewedDetailUiState.Error::class.java) + assertThat((state as MostViewedDetailUiState.Error).message).isEqualTo(API_ERROR) + verify(statsRepository, never()).fetchReferrersDetail(any(), any()) + } + + @Test + fun `when the repository returns an error, then the api error state is emitted`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())) + .thenReturn(MostViewedResult.Error("Network error")) + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(MostViewedDetailUiState.Error::class.java) + assertThat((state as MostViewedDetailUiState.Error).message).isEqualTo(API_ERROR) + } + + @Test + fun `when the fetch throws, then the generic unknown error state is emitted`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())) + .thenThrow(RuntimeException(EXCEPTION_MESSAGE)) + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(MostViewedDetailUiState.Error::class.java) + assertThat((state as MostViewedDetailUiState.Error).message).isEqualTo(UNKNOWN_ERROR) + } + + @Test + fun `when loadReferrers is called twice, then the data is fetched only once`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + + verify(statsRepository, times(1)).fetchReferrersDetail(any(), any()) + } + + @Test + fun `when retry is called after a load, then the data is fetched again`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) + + viewModel.loadReferrers(StatsPeriod.Last7Days) + advanceUntilIdle() + viewModel.retry() + advanceUntilIdle() + + verify(statsRepository, times(2)).fetchReferrersDetail(any(), any()) + } + + private fun createSuccessResult() = MostViewedResult.Success( + items = listOf( + MostViewedItemData( + id = 1, + title = "Search Engines", + views = TEST_VIEWS, + previousViews = TEST_PREVIOUS_VIEWS, + isFirst = true, + children = listOf( + MostViewedChildData(name = "Google Search", url = CHILD_URL, views = TEST_VIEWS) + ) + ) + ), + totalViews = TEST_TOTAL_VIEWS, + totalViewsChange = TEST_TOTAL_VIEWS_CHANGE, + totalViewsChangePercent = TEST_TOTAL_VIEWS_CHANGE_PERCENT + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val DATE_RANGE = "Last 7 days" + private const val API_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = "Something went wrong" + private const val EXCEPTION_MESSAGE = "boom" + private const val CHILD_URL = "http://google.com/" + + private const val TEST_VIEWS = 23L + private const val TEST_PREVIOUS_VIEWS = 10L + private const val TEST_TOTAL_VIEWS = 23L + private const val TEST_TOTAL_VIEWS_CHANGE = 13L + private const val TEST_TOTAL_VIEWS_CHANGE_PERCENT = 130.0 + } +} 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 d99b5fdba58d..da0c4cac684b 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 @@ -509,22 +509,6 @@ class MostViewedViewModelTest : BaseUnitTest() { assertThat(detailData.totalViewsChangePercent).isEqualTo(TEST_TOTAL_VIEWS_CHANGE_PERCENT) } - @Test - fun `when getReferrersDetailData is called, then returns cached referrers data`() = test { - whenever(statsRepository.fetchMostViewed(any(), any(), any())) - .thenReturn(createSuccessResult()) - whenever(resourceProvider.getString(R.string.stats_period_last_7_days)) - .thenReturn("Last 7 days") - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getReferrersDetailData() - - assertThat(detailData.cardType).isEqualTo(StatsCardType.MOST_VIEWED_REFERRERS) - assertThat(detailData.items).hasSize(2) - } - @Test fun `when getPostsDetailData is called, then all items are returned not just card items`() = test { val manyItems = (1..15).mapIndexed { idx, index -> diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryReferrersTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryReferrersTest.kt new file mode 100644 index 000000000000..55784967b85e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryReferrersTest.kt @@ -0,0 +1,145 @@ +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.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.ui.newstats.datasource.ReferrerChildDataItem +import org.wordpress.android.ui.newstats.datasource.ReferrerDataItem +import org.wordpress.android.ui.newstats.datasource.ReferrersDataResult +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsErrorType +import org.wordpress.android.ui.newstats.mostviewed.MostViewedDataSource + +@ExperimentalCoroutinesApi +class StatsRepositoryReferrersTest : BaseUnitTest() { + @Mock + private lateinit var statsDataSource: StatsDataSource + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + repository = StatsRepository( + statsDataSource = statsDataSource, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + @Test + fun `given successful response, when fetchMostViewed with REFERRERS, then success result is returned`() = test { + whenever(statsDataSource.fetchReferrers(any(), any(), any())) + .thenReturn(ReferrersDataResult.Success(createReferrersData())) + + val result = repository.fetchMostViewed( + TEST_SITE_ID, + StatsPeriod.Last7Days, + MostViewedDataSource.REFERRERS + ) + + assertThat(result).isInstanceOf(MostViewedResult.Success::class.java) + val success = result as MostViewedResult.Success + assertThat(success.items).hasSize(2) + assertThat(success.items[0].title).isEqualTo(TEST_REFERRER_NAME_1) + assertThat(success.items[0].views).isEqualTo(TEST_REFERRER_VIEWS_1) + assertThat(success.items[1].title).isEqualTo(TEST_REFERRER_NAME_2) + assertThat(success.items[1].views).isEqualTo(TEST_REFERRER_VIEWS_2) + } + + @Test + fun `given referrer group with children, when fetchMostViewed with REFERRERS, then children propagate`() = test { + val referrersData = listOf( + ReferrerDataItem( + name = "Search Engines", + views = 23, + children = listOf( + ReferrerChildDataItem(name = "Google Search", url = "http://google.com/", views = 23) + ) + ) + ) + whenever(statsDataSource.fetchReferrers(any(), any(), any())) + .thenReturn(ReferrersDataResult.Success(referrersData)) + + val result = repository.fetchMostViewed( + TEST_SITE_ID, + StatsPeriod.Last7Days, + MostViewedDataSource.REFERRERS + ) + + assertThat(result).isInstanceOf(MostViewedResult.Success::class.java) + val success = result as MostViewedResult.Success + assertThat(success.items[0].children).hasSize(1) + assertThat(success.items[0].children[0].name).isEqualTo("Google Search") + assertThat(success.items[0].children[0].url).isEqualTo("http://google.com/") + assertThat(success.items[0].children[0].views).isEqualTo(23) + } + + @Test + fun `when fetchMostViewed with REFERRERS, then card-sized max is requested`() = test { + whenever(statsDataSource.fetchReferrers(any(), any(), any())) + .thenReturn(ReferrersDataResult.Success(createReferrersData())) + + repository.fetchMostViewed(TEST_SITE_ID, StatsPeriod.Last7Days, MostViewedDataSource.REFERRERS) + + // Card request is bounded (current + previous period). + verify(statsDataSource, times(2)).fetchReferrers(any(), any(), eq(REFERRERS_CARD_MAX)) + } + + @Test + fun `when fetchReferrersDetail, then unlimited max is requested`() = test { + whenever(statsDataSource.fetchReferrers(any(), any(), any())) + .thenReturn(ReferrersDataResult.Success(createReferrersData())) + + repository.fetchReferrersDetail(TEST_SITE_ID, StatsPeriod.Last7Days) + + // Detail request asks for all referrers (max = 0), for current + previous period. + verify(statsDataSource, times(2)).fetchReferrers(any(), any(), eq(REFERRERS_DETAIL_MAX)) + } + + @Test + fun `given error response, when fetchMostViewed with REFERRERS, then error result is returned`() = test { + whenever(statsDataSource.fetchReferrers(any(), any(), any())) + .thenReturn(ReferrersDataResult.Error(TEST_ERROR_TYPE)) + + val result = repository.fetchMostViewed( + TEST_SITE_ID, + StatsPeriod.Last7Days, + MostViewedDataSource.REFERRERS + ) + + assertThat(result).isInstanceOf(MostViewedResult.Error::class.java) + assertThat((result as MostViewedResult.Error).message).isEqualTo(TEST_ERROR_TYPE.name) + } + + private fun createReferrersData() = listOf( + ReferrerDataItem(name = TEST_REFERRER_NAME_1, views = TEST_REFERRER_VIEWS_1), + ReferrerDataItem(name = TEST_REFERRER_NAME_2, views = TEST_REFERRER_VIEWS_2) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private val TEST_ERROR_TYPE = StatsErrorType.NETWORK_ERROR + + // Mirrors StatsRepository's private referrer max values (card vs. detail). + private const val REFERRERS_CARD_MAX = 10 + private const val REFERRERS_DETAIL_MAX = 0 + + private const val TEST_REFERRER_NAME_1 = "google.com" + private const val TEST_REFERRER_NAME_2 = "twitter.com" + private const val TEST_REFERRER_VIEWS_1 = 200L + private const val TEST_REFERRER_VIEWS_2 = 150L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt index 8445ed76e203..f0ad9af260b8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt @@ -5,8 +5,6 @@ import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.datasource.CommentsDataPoint import org.wordpress.android.ui.newstats.datasource.LikesDataPoint import org.wordpress.android.ui.newstats.datasource.PostsDataPoint -import org.wordpress.android.ui.newstats.datasource.ReferrerDataItem -import org.wordpress.android.ui.newstats.datasource.ReferrersDataResult import org.wordpress.android.ui.newstats.datasource.StatsDataSource import org.wordpress.android.ui.newstats.datasource.StatsErrorType import org.wordpress.android.ui.newstats.datasource.StatsUnit @@ -546,26 +544,6 @@ class StatsRepositoryTest : BaseUnitTest() { assertThat(success.items[1].views).isEqualTo(TEST_POST_VIEWS_2) } - @Test - fun `given successful response, when fetchMostViewed with REFERRERS, then success result is returned`() = test { - whenever(statsDataSource.fetchReferrers(any(), any(), any())) - .thenReturn(ReferrersDataResult.Success(createReferrersData())) - - val result = repository.fetchMostViewed( - TEST_SITE_ID, - StatsPeriod.Last7Days, - MostViewedDataSource.REFERRERS - ) - - assertThat(result).isInstanceOf(MostViewedResult.Success::class.java) - val success = result as MostViewedResult.Success - assertThat(success.items).hasSize(2) - assertThat(success.items[0].title).isEqualTo(TEST_REFERRER_NAME_1) - assertThat(success.items[0].views).isEqualTo(TEST_REFERRER_VIEWS_1) - assertThat(success.items[1].title).isEqualTo(TEST_REFERRER_NAME_2) - assertThat(success.items[1].views).isEqualTo(TEST_REFERRER_VIEWS_2) - } - @Test fun `given successful response, when fetchMostViewed, then totalViews is calculated correctly`() = test { whenever(statsDataSource.fetchTopPostsAndPages(any(), any(), any())) @@ -626,21 +604,6 @@ class StatsRepositoryTest : BaseUnitTest() { assertThat((result as MostViewedResult.Error).message).isEqualTo(TEST_ERROR_TYPE.name) } - @Test - fun `given error response, when fetchMostViewed with REFERRERS, then error result is returned`() = test { - whenever(statsDataSource.fetchReferrers(any(), any(), any())) - .thenReturn(ReferrersDataResult.Error(TEST_ERROR_TYPE)) - - val result = repository.fetchMostViewed( - TEST_SITE_ID, - StatsPeriod.Last7Days, - MostViewedDataSource.REFERRERS - ) - - assertThat(result).isInstanceOf(MostViewedResult.Error::class.java) - assertThat((result as MostViewedResult.Error).message).isEqualTo(TEST_ERROR_TYPE.name) - } - @Test fun `given item in both periods, when fetchMostViewed, then viewsChange is calculated for item`() = test { val currentData = listOf( @@ -749,11 +712,6 @@ class StatsRepositoryTest : BaseUnitTest() { TopPostDataItem(id = 1, title = TEST_POST_TITLE_1, views = TEST_POST_VIEWS_1), TopPostDataItem(id = 2, title = TEST_POST_TITLE_2, views = TEST_POST_VIEWS_2) ) - - private fun createReferrersData() = listOf( - ReferrerDataItem(name = TEST_REFERRER_NAME_1, views = TEST_REFERRER_VIEWS_1), - ReferrerDataItem(name = TEST_REFERRER_NAME_2, views = TEST_REFERRER_VIEWS_2) - ) // endregion companion object { @@ -785,10 +743,5 @@ class StatsRepositoryTest : BaseUnitTest() { private const val TEST_POST_TITLE_2 = "Test Post 2" private const val TEST_POST_VIEWS_1 = 500L private const val TEST_POST_VIEWS_2 = 300L - - private const val TEST_REFERRER_NAME_1 = "google.com" - private const val TEST_REFERRER_NAME_2 = "twitter.com" - private const val TEST_REFERRER_VIEWS_1 = 200L - private const val TEST_REFERRER_VIEWS_2 = 150L } }