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 e9c24172f624..d030e3463f80 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 @@ -77,6 +77,7 @@ import org.wordpress.android.ui.newstats.locations.LocationsViewModel import org.wordpress.android.ui.newstats.mostviewed.MostViewedCard import org.wordpress.android.ui.newstats.mostviewed.MostViewedCardUiState import org.wordpress.android.ui.newstats.mostviewed.MostViewedDetailActivity +import org.wordpress.android.ui.newstats.mostviewed.MostViewedDetailSource import org.wordpress.android.ui.newstats.mostviewed.MostViewedViewModel import org.wordpress.android.ui.newstats.todaysstats.TodaysStatsCard import org.wordpress.android.ui.newstats.todaysstats.TodaysStatsViewModel @@ -692,9 +693,11 @@ private fun TrafficTabContent( cardType = cardType, onShowAllClick = { // 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( + // only the source + period are passed instead of the whole item list. + MostViewedDetailActivity.startSelfFetch( context = context, + cardType = StatsCardType.MOST_VIEWED_REFERRERS, + source = MostViewedDetailSource.REFERRERS, period = mostViewedViewModel.getCurrentPeriod() ) }, @@ -806,14 +809,11 @@ private fun TrafficTabContent( StatsCardType.AUTHORS -> AuthorsCard( uiState = authorsUiState, onShowAllClick = { - val detailData = authorsViewModel.getDetailData() - AuthorsDetailActivity.start( + // The authors detail screen self-fetches the full (unbounded) list, so + // only the selected period is passed instead of the whole item list. + AuthorsDetailActivity.startSelfFetch( context = context, - authors = detailData.authors, - totalViews = detailData.totalViews, - totalViewsChange = detailData.totalViewsChange, - totalViewsChangePercent = detailData.totalViewsChangePercent, - dateRange = detailData.dateRange + period = authorsViewModel.getCurrentPeriod() ) }, onRetry = authorsViewModel::onRetry, @@ -835,16 +835,11 @@ private fun TrafficTabContent( uiState = clicksUiState, cardType = cardType, onShowAllClick = { - val detailData = clicksViewModel.getDetailData() - MostViewedDetailActivity.start( + MostViewedDetailActivity.startSelfFetch( context = context, - cardType = detailData.cardType, - items = detailData.items, - totalViews = detailData.totalViews, - totalViewsChange = detailData.totalViewsChange, - totalViewsChangePercent = - detailData.totalViewsChangePercent, - dateRange = detailData.dateRange, + cardType = StatsCardType.CLICKS, + source = MostViewedDetailSource.CLICKS, + period = clicksViewModel.getCurrentPeriod(), valueHeaderResId = R.string.stats_clicks_label ) }, @@ -875,17 +870,11 @@ private fun TrafficTabContent( uiState = searchTermsUiState, cardType = cardType, onShowAllClick = { - val detailData = - searchTermsViewModel.getDetailData() - MostViewedDetailActivity.start( + MostViewedDetailActivity.startSelfFetch( context = context, - cardType = detailData.cardType, - items = detailData.items, - totalViews = detailData.totalViews, - totalViewsChange = detailData.totalViewsChange, - totalViewsChangePercent = - detailData.totalViewsChangePercent, - dateRange = detailData.dateRange + cardType = StatsCardType.SEARCH_TERMS, + source = MostViewedDetailSource.SEARCH_TERMS, + period = searchTermsViewModel.getCurrentPeriod() ) }, onRetry = searchTermsViewModel::onRetry, @@ -916,17 +905,11 @@ private fun TrafficTabContent( uiState = videoPlaysUiState, cardType = cardType, onShowAllClick = { - val detailData = - videoPlaysViewModel.getDetailData() - MostViewedDetailActivity.start( + MostViewedDetailActivity.startSelfFetch( context = context, - cardType = detailData.cardType, - items = detailData.items, - totalViews = detailData.totalViews, - totalViewsChange = detailData.totalViewsChange, - totalViewsChangePercent = - detailData.totalViewsChangePercent, - dateRange = detailData.dateRange + cardType = StatsCardType.VIDEO_PLAYS, + source = MostViewedDetailSource.VIDEO_PLAYS, + period = videoPlaysViewModel.getCurrentPeriod() ) }, onRetry = videoPlaysViewModel::onRetry, @@ -957,17 +940,11 @@ private fun TrafficTabContent( uiState = fileDownloadsUiState, cardType = cardType, onShowAllClick = { - val detailData = - fileDownloadsViewModel.getDetailData() - MostViewedDetailActivity.start( + MostViewedDetailActivity.startSelfFetch( context = context, - cardType = detailData.cardType, - items = detailData.items, - totalViews = detailData.totalViews, - totalViewsChange = detailData.totalViewsChange, - totalViewsChangePercent = - detailData.totalViewsChangePercent, - dateRange = detailData.dateRange, + cardType = StatsCardType.FILE_DOWNLOADS, + source = MostViewedDetailSource.FILE_DOWNLOADS, + period = fileDownloadsViewModel.getCurrentPeriod(), valueHeaderResId = R.string.stats_file_downloads_value_label ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt index 54e477f9b237..6eb14f575918 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt @@ -4,6 +4,10 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height @@ -12,13 +16,19 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +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 +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -27,64 +37,61 @@ 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.StatsPeriod import org.wordpress.android.ui.newstats.components.StatsDetailListItem import org.wordpress.android.ui.newstats.components.StatsListHeader import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.components.StatsViewChange -import org.wordpress.android.util.extensions.getParcelableArrayListCompat -private const val EXTRA_AUTHORS = "extra_authors" -private const val EXTRA_TOTAL_VIEWS = "extra_total_views" -private const val EXTRA_TOTAL_VIEWS_CHANGE = "extra_total_views_change" -private const val EXTRA_TOTAL_VIEWS_CHANGE_PERCENT = "extra_total_views_change_percent" -private const val EXTRA_DATE_RANGE = "extra_date_range" +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 AuthorsDetailActivity : BaseAppCompatActivity() { + private val viewModel: AuthorsDetailViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val authors = intent.extras - ?.getParcelableArrayListCompat(EXTRA_AUTHORS) - ?: 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) ?: "" - // Calculate maxViewsForBar once (list is sorted by views descending) - val maxViewsForBar = authors.firstOrNull()?.views ?: 0L + viewModel.load(readPeriod()) setContent { AppThemeM3 { + val uiState by viewModel.uiState.collectAsState() AuthorsDetailScreen( - authors = authors, - maxViewsForBar = maxViewsForBar, - totalViews = totalViews, - totalViewsChange = totalViewsChange, - totalViewsChangePercent = totalViewsChangePercent, - dateRange = dateRange, - onBackPressed = onBackPressedDispatcher::onBackPressed + uiState = uiState, + onBackPressed = onBackPressedDispatcher::onBackPressed, + onRetry = { viewModel.retry() } ) } } } + private fun readPeriod(): StatsPeriod { + val periodType = intent.getStringExtra(EXTRA_PERIOD_TYPE) ?: return StatsPeriod.Last7Days + 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 } + ) + } + companion object { - @Suppress("LongParameterList") - fun start( - context: Context, - authors: List, - totalViews: Long, - totalViewsChange: Long, - totalViewsChangePercent: Double, - dateRange: String - ) { + /** + * Launches the authors detail screen, which self-fetches the full (unbounded) authors list + * for [period] rather than receiving it through the Intent (see [AuthorsDetailViewModel]). + */ + fun startSelfFetch(context: Context, period: StatsPeriod) { val intent = Intent(context, AuthorsDetailActivity::class.java).apply { - putExtra(EXTRA_AUTHORS, ArrayList(authors)) - putExtra(EXTRA_TOTAL_VIEWS, totalViews) - putExtra(EXTRA_TOTAL_VIEWS_CHANGE, totalViewsChange) - putExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, totalViewsChangePercent) - putExtra(EXTRA_DATE_RANGE, dateRange) + 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) } @@ -94,13 +101,9 @@ class AuthorsDetailActivity : BaseAppCompatActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AuthorsDetailScreen( - authors: List, - maxViewsForBar: Long, - totalViews: Long, - totalViewsChange: Long, - totalViewsChangePercent: Double, - dateRange: String, - onBackPressed: () -> Unit + uiState: AuthorsDetailUiState, + onBackPressed: () -> Unit, + onRetry: () -> Unit = {} ) { Scaffold( topBar = { @@ -117,46 +120,88 @@ private fun AuthorsDetailScreen( ) } ) { 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 AuthorsDetailUiState.Loading -> AuthorsLoadingContent(contentModifier) + is AuthorsDetailUiState.Error -> AuthorsErrorContent(uiState.message, onRetry, contentModifier) + is AuthorsDetailUiState.Loaded -> AuthorsLoadedContent(uiState, contentModifier) + } + } +} - item { - StatsListHeader(leftHeaderResId = R.string.stats_authors_author_header) - Spacer(modifier = Modifier.height(8.dp)) - } +@Composable +private fun AuthorsLoadedContent( + state: AuthorsDetailUiState.Loaded, + 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(authors) { index, author -> - val percentage = if (maxViewsForBar > 0) { - author.views.toFloat() / maxViewsForBar.toFloat() - } else 0f - DetailAuthorRow( - position = index + 1, - author = author, - percentage = percentage - ) - if (index < authors.lastIndex) { - Spacer(modifier = Modifier.height(4.dp)) - } - } + item { + StatsListHeader(leftHeaderResId = R.string.stats_authors_author_header) + Spacer(modifier = Modifier.height(8.dp)) + } - item { - Spacer(modifier = Modifier.height(16.dp)) + itemsIndexed(state.authors) { index, author -> + val percentage = if (state.maxViewsForBar > 0) { + author.views.toFloat() / state.maxViewsForBar.toFloat() + } else 0f + DetailAuthorRow( + position = index + 1, + author = author, + percentage = percentage + ) + if (index < state.authors.lastIndex) { + Spacer(modifier = Modifier.height(4.dp)) } } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun AuthorsLoadingContent(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun AuthorsErrorContent( + 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)) + } } } @@ -181,23 +226,20 @@ private fun DetailAuthorRow( private fun AuthorsDetailScreenPreview() { AppThemeM3 { AuthorsDetailScreen( - authors = listOf( - AuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)), - AuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)), - AuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)), - AuthorUiItem("Alice Brown", null, 485, StatsViewChange.Positive(33, 7.3)), - AuthorUiItem("Charlie Wilson", null, 412, StatsViewChange.NoChange), - AuthorUiItem("Diana Miller", null, 387, StatsViewChange.Negative(8, 2.0)), - AuthorUiItem("Edward Davis", null, 298, StatsViewChange.Positive(21, 7.6)), - AuthorUiItem("Fiona Garcia", null, 245, StatsViewChange.Positive(15, 6.5)), - AuthorUiItem("George Martinez", null, 201, StatsViewChange.Negative(5, 2.4)), - AuthorUiItem("Hannah Anderson", null, 156, StatsViewChange.Positive(12, 8.3)) + uiState = AuthorsDetailUiState.Loaded( + authors = listOf( + AuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)), + AuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)), + AuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)), + AuthorUiItem("Alice Brown", null, 485, StatsViewChange.Positive(33, 7.3)), + AuthorUiItem("Charlie Wilson", null, 412, StatsViewChange.NoChange) + ), + maxViewsForBar = 3464, + totalViews = 6726, + totalViewsChange = 225, + totalViewsChangePercent = 3.5, + dateRange = "Last 7 days" ), - maxViewsForBar = 3464, - totalViews = 6726, - totalViewsChange = 225, - totalViewsChangePercent = 3.5, - dateRange = "Last 7 days", onBackPressed = {} ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailViewModel.kt new file mode 100644 index 000000000000..fca9997003b7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailViewModel.kt @@ -0,0 +1,106 @@ +package org.wordpress.android.ui.newstats.authors + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.TopAuthorsResult +import org.wordpress.android.ui.newstats.util.toDateRangeString +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +/** + * Backs [AuthorsDetailActivity]. The authors card only shows the first N authors, so the detail + * screen re-requests the full, unbounded list (max = 0) itself instead of receiving it through the + * Intent, which could exceed the Binder transaction limit for sites with many authors. + */ +@HiltViewModel +class AuthorsDetailViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = MutableStateFlow(AuthorsDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private var period: StatsPeriod? = null + private var hasStartedLoading = false + + /** + * Loads the authors detail data for [period]. Safe to call on every [AuthorsDetailActivity] + * 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 load(period: StatsPeriod) { + this.period = period + if (hasStartedLoading) return + fetch() + } + + fun retry() = fetch() + + private fun fetch() { + val period = period ?: return + val site = selectedSiteRepository.getSelectedSite() + val accessToken = accountStore.accessToken + if (site == null || accessToken.isNullOrEmpty()) { + _uiState.value = AuthorsDetailUiState.Error(resourceProvider.getString(R.string.stats_error_api)) + return + } + hasStartedLoading = true + statsRepository.init(accessToken) + _uiState.value = AuthorsDetailUiState.Loading + viewModelScope.launch { + _uiState.value = fetchAuthors(site.siteId, period) + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun fetchAuthors(siteId: Long, period: StatsPeriod): AuthorsDetailUiState = + try { + when (val result = statsRepository.fetchTopAuthors(siteId, period)) { + is TopAuthorsResult.Success -> { + val authors = result.authors.map { it.toAuthorUiItem() } + AuthorsDetailUiState.Loaded( + authors = authors, + maxViewsForBar = authors.firstOrNull()?.views ?: 0L, + totalViews = result.totalViews, + totalViewsChange = result.totalViewsChange, + totalViewsChangePercent = result.totalViewsChangePercent, + dateRange = period.toDateRangeString(resourceProvider) + ) + } + is TopAuthorsResult.Error -> AuthorsDetailUiState.Error( + resourceProvider.getString(result.messageResId) + ) + } + } catch (e: Exception) { + AuthorsDetailUiState.Error( + e.message ?: resourceProvider.getString(R.string.stats_error_unknown) + ) + } +} + +sealed interface AuthorsDetailUiState { + data object Loading : AuthorsDetailUiState + + data class Loaded( + val authors: List, + val maxViewsForBar: Long, + val totalViews: Long, + val totalViewsChange: Long, + val totalViewsChangePercent: Double, + val dateRange: String + ) : AuthorsDetailUiState + + data class Error(val message: String) : AuthorsDetailUiState +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt index 98c0eeec030f..28c78b32b2e7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt @@ -107,6 +107,12 @@ class AuthorsViewModel @Inject constructor( loadData() } + /** + * The period currently selected for the authors card. The authors detail screen self-fetches its + * own (unbounded) data for this period rather than receiving the full list via the Intent. + */ + fun getCurrentPeriod(): StatsPeriod = currentPeriod + fun getDetailData(): AuthorsDetailData { return AuthorsDetailData( authors = allAuthors, @@ -140,14 +146,7 @@ class AuthorsViewModel @Inject constructor( hasMoreItems = false ) } else { - val authors = result.authors.map { author -> - AuthorUiItem( - name = author.name, - avatarUrl = author.avatarUrl, - views = author.views, - change = author.toStatsViewChange() - ) - } + val authors = result.authors.map { it.toAuthorUiItem() } allAuthors = authors @@ -178,15 +177,23 @@ class AuthorsViewModel @Inject constructor( } } - private fun TopAuthorItemData.toStatsViewChange(): StatsViewChange { - return when { - viewsChange > 0 -> StatsViewChange.Positive(viewsChange, abs(viewsChangePercent)) - viewsChange < 0 -> StatsViewChange.Negative(abs(viewsChange), abs(viewsChangePercent)) - else -> StatsViewChange.NoChange - } - } } +/** + * Maps a repository [TopAuthorItemData] to the parcelable [AuthorUiItem] shown by the card and the + * detail screen. Shared by [AuthorsViewModel] and [AuthorsDetailViewModel]. + */ +internal fun TopAuthorItemData.toAuthorUiItem() = AuthorUiItem( + name = name, + avatarUrl = avatarUrl, + views = views, + change = when { + viewsChange > 0 -> StatsViewChange.Positive(viewsChange, abs(viewsChangePercent)) + viewsChange < 0 -> StatsViewChange.Negative(abs(viewsChange), abs(viewsChangePercent)) + else -> StatsViewChange.NoChange + } +) + data class AuthorsDetailData( val authors: List, val totalViews: Long, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt index 5a075ee73a3c..6d4415e0211a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt @@ -137,6 +137,12 @@ abstract class BaseStatsCardViewModel( loadData() } + /** + * The period currently selected for this card. The detail screen self-fetches its own (unbounded) + * data for this period rather than receiving the full list via the Intent. + */ + fun getCurrentPeriod(): StatsPeriod = currentPeriod + fun getDetailData(): MostViewedDetailData { return MostViewedDetailData( cardType = cardType, 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 42f16b1f3682..2de0c1d214d9 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 @@ -2,11 +2,14 @@ package org.wordpress.android.ui.newstats.mostviewed import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable 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 @@ -17,13 +20,15 @@ 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.mutableStateOf +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 org.wordpress.android.R @@ -34,11 +39,13 @@ 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 @@ -196,20 +203,7 @@ private fun LoadedContent( val percentage = if (state.maxViewsForBar > 0) { item.views.toFloat() / state.maxViewsForBar.toFloat() } else 0f - // 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 - ) - } + MostViewedItemRow(item = item, percentage = percentage, onChildClick = onChildClick) if (index < state.items.lastIndex) { Spacer(modifier = Modifier.height(4.dp)) } @@ -292,6 +286,82 @@ private fun ColumnHeadersRow(cardType: StatsCardType) { } } +@Composable +private fun MostViewedItemRow( + item: MostViewedItem, + percentage: Float, + onChildClick: (String) -> Unit +) { + val barColor = MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHTED_ITEM_BACKGROUND_ALPHA) + val hasChildren = item.children.isNotEmpty() + var expanded by remember { mutableStateOf(false) } + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(8.dp)) + .then( + if (hasChildren) Modifier.clickable { expanded = !expanded } else Modifier + ) + ) { + // 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) { + if (hasChildren) { + MostViewedExpandChevron(expanded = expanded) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = formatStatValue(item.views), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + ChangeIndicator(change = item.change) + } + } + } + } + + MostViewedExpandableChildren( + children = item.children, + expanded = expanded, + startPadding = 24.dp, + onChildClick = onChildClick + ) + } +} + @Composable internal fun ChangeIndicator(change: MostViewedChange) { val (text, color) = when (change) { 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 index 0d8a67786e14..8eb9c2e01762 100644 --- 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 @@ -11,21 +11,16 @@ 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.ChevronRight 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 @@ -33,92 +28,6 @@ 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]). @@ -196,8 +105,8 @@ private fun MostViewedChildRow( if (isClickable) { Spacer(modifier = Modifier.width(4.dp)) Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = stringResource(R.string.stats_open_referrer_link), + imageVector = Icons.Default.ChevronRight, + contentDescription = null, 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 f38e59098cca..768e505f4ade 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 @@ -5,17 +5,23 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable 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.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.material3.Button @@ -30,11 +36,16 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.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 @@ -45,6 +56,7 @@ 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 @@ -57,9 +69,10 @@ 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 +// Self-fetch mode: the detail screen re-fetches its own unbounded data for this source/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.). +// the items passed via EXTRA_ITEMS (e.g. posts, which fetch a bounded list). +private const val EXTRA_DETAIL_SOURCE = "extra_detail_source" 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" @@ -77,15 +90,15 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { val cardType = intent.extras?.getSerializableCompat(EXTRA_CARD_TYPE) ?: StatsCardType.MOST_VIEWED_POSTS_AND_PAGES val valueHeaderResId = intent.getIntExtra(EXTRA_VALUE_HEADER_RES_ID, R.string.stats_views) - val selfFetchPeriod = readSelfFetchPeriod() + val selfFetch = readSelfFetchArgs() - if (selfFetchPeriod != null) { - viewModel.loadReferrers(selfFetchPeriod) + if (selfFetch != null) { + viewModel.load(selfFetch.source, selfFetch.period) } setContent { AppThemeM3 { - val uiState = if (selfFetchPeriod != null) { + val uiState = if (selfFetch != null) { viewModel.uiState.collectAsState().value } else { remember { intent.toPassedItemsState() } @@ -103,20 +116,24 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { } /** - * 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.). + * Reads the source + period for self-fetch mode, or null when the screen should render the items + * passed via the Intent (e.g. posts). */ - private fun readSelfFetchPeriod(): StatsPeriod? { + private fun readSelfFetchArgs(): SelfFetchArgs? { + val sourceName = intent.getStringExtra(EXTRA_DETAIL_SOURCE) ?: return null 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( + val period = StatsPeriod.fromTypeString( type = periodType, customStartEpochDay = customStart.takeIf { it != NO_EPOCH_DAY }, customEndEpochDay = customEnd.takeIf { it != NO_EPOCH_DAY } ) + return SelfFetchArgs(MostViewedDetailSource.valueOf(sourceName), period) } + private data class SelfFetchArgs(val source: MostViewedDetailSource, val period: StatsPeriod) + private fun Intent.toPassedItemsState(): MostViewedDetailUiState.Loaded { val items = extras?.getParcelableArrayListCompat(EXTRA_ITEMS) ?: arrayListOf() @@ -161,12 +178,21 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { } /** - * 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]). + * Launches the detail screen in self-fetch mode: only the [source], [period] and header are + * passed and the screen re-fetches the full (unbounded) list itself, instead of receiving it + * through the Intent (see [MostViewedDetailViewModel]). */ - fun startReferrers(context: Context, period: StatsPeriod) { + fun startSelfFetch( + context: Context, + cardType: StatsCardType, + source: MostViewedDetailSource, + period: StatsPeriod, + valueHeaderResId: Int = R.string.stats_views + ) { val intent = Intent(context, MostViewedDetailActivity::class.java).apply { - putExtra(EXTRA_CARD_TYPE, StatsCardType.MOST_VIEWED_REFERRERS) + putExtra(EXTRA_CARD_TYPE, cardType) + putExtra(EXTRA_DETAIL_SOURCE, source.name) + putExtra(EXTRA_VALUE_HEADER_RES_ID, valueHeaderResId) putExtra(EXTRA_PERIOD_TYPE, period.toTypeString()) if (period is StatsPeriod.Custom) { putExtra(EXTRA_PERIOD_CUSTOM_START, period.startDate.toEpochDay()) @@ -254,18 +280,11 @@ private fun DetailLoadedContent( // 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, + DetailItemRow( position = index + 1, - childStartPadding = 32.dp + item = item, + maxViewsForBar = state.maxViewsForBar, + onChildClick = onChildClick ) if (index < state.items.lastIndex) { Spacer(modifier = Modifier.height(4.dp)) @@ -332,6 +351,87 @@ private fun ColumnHeaders( } } +@Composable +private fun DetailItemRow( + position: Int, + item: MostViewedDetailItem, + maxViewsForBar: Long, + onChildClick: (String) -> Unit +) { + val percentage = if (maxViewsForBar > 0) item.views.toFloat() / maxViewsForBar.toFloat() else 0f + val barColor = MaterialTheme.colorScheme.primary.copy( + alpha = HIGHLIGHTED_ITEM_BACKGROUND_ALPHA + ) + val hasChildren = item.children.isNotEmpty() + var expanded by rememberSaveable { mutableStateOf(false) } + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(8.dp)) + .then( + if (hasChildren) Modifier.clickable { expanded = !expanded } else Modifier + ) + ) { + 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) { + if (hasChildren) { + MostViewedExpandChevron(expanded = expanded) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = formatStatValue(item.views), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + } + ChangeIndicator(change = item.change) + } + } + } + + MostViewedExpandableChildren( + children = item.children, + expanded = expanded, + startPadding = 32.dp, + onChildClick = onChildClick + ) + } +} + @Preview(showBackground = true) @Composable private fun MostViewedDetailScreenPreview() { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcher.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcher.kt new file mode 100644 index 000000000000..2cd1d1e4c84c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcher.kt @@ -0,0 +1,133 @@ +package org.wordpress.android.ui.newstats.mostviewed + +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.ui.newstats.repository.ClickItemData +import org.wordpress.android.ui.newstats.repository.ClicksResult +import org.wordpress.android.ui.newstats.repository.FileDownloadItemData +import org.wordpress.android.ui.newstats.repository.FileDownloadsResult +import org.wordpress.android.ui.newstats.repository.MostViewedResult +import org.wordpress.android.ui.newstats.repository.SearchTermItemData +import org.wordpress.android.ui.newstats.repository.SearchTermsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.VideoPlayItemData +import org.wordpress.android.ui.newstats.repository.VideoPlaysResult +import javax.inject.Inject + +/** + * Which data source a self-fetching Most Viewed detail screen should load. These are the sources + * whose detail screen shows the full (unbounded, max = 0) list, so they re-fetch it themselves + * rather than receiving it through the launching Intent (which risked TransactionTooLargeException). + */ +enum class MostViewedDetailSource { + REFERRERS, + CLICKS, + SEARCH_TERMS, + VIDEO_PLAYS, + FILE_DOWNLOADS +} + +/** + * Fetches the full detail list for a [MostViewedDetailSource] and maps it to the common + * [StatsCardFetchResult] shape used by [MostViewedDetailViewModel]. + * + * The per-item mapping mirrors the corresponding card ViewModels (e.g. `ClicksViewModel`); the card + * shows the first N items while the detail screen shows them all. + */ +class MostViewedDetailFetcher @Inject constructor( + private val statsRepository: StatsRepository +) { + suspend fun fetch( + source: MostViewedDetailSource, + siteId: Long, + period: StatsPeriod, + accessToken: String + ): StatsCardFetchResult { + statsRepository.init(accessToken) + return when (source) { + MostViewedDetailSource.REFERRERS -> statsRepository.fetchReferrersDetail(siteId, period).toFetchResult() + MostViewedDetailSource.CLICKS -> statsRepository.fetchClicks(siteId, period).toFetchResult() + MostViewedDetailSource.SEARCH_TERMS -> statsRepository.fetchSearchTerms(siteId, period).toFetchResult() + MostViewedDetailSource.VIDEO_PLAYS -> statsRepository.fetchVideoPlays(siteId, period).toFetchResult() + MostViewedDetailSource.FILE_DOWNLOADS -> statsRepository.fetchFileDownloads(siteId, period).toFetchResult() + } + } + + private fun MostViewedResult.toFetchResult() = when (this) { + is MostViewedResult.Success -> StatsCardFetchResult.Success( + items = items.map { it.toDetailItem() }, + totalValue = totalViews, + totalValueChange = totalViewsChange, + totalValueChangePercent = totalViewsChangePercent + ) + is MostViewedResult.Error -> StatsCardFetchResult.Error(R.string.stats_error_api) + } + + private fun ClicksResult.toFetchResult() = when (this) { + is ClicksResult.Success -> StatsCardFetchResult.Success( + items = items.mapIndexed { i, item -> item.toDetailItem(i.toLong()) }, + totalValue = totalClicks, + totalValueChange = totalClicksChange, + totalValueChangePercent = totalClicksChangePercent + ) + is ClicksResult.Error -> StatsCardFetchResult.Error(messageResId, isAuthError) + } + + private fun SearchTermsResult.toFetchResult() = when (this) { + is SearchTermsResult.Success -> StatsCardFetchResult.Success( + items = items.mapIndexed { i, item -> item.toDetailItem(i.toLong()) }, + totalValue = totalViews, + totalValueChange = totalViewsChange, + totalValueChangePercent = totalViewsChangePercent + ) + is SearchTermsResult.Error -> StatsCardFetchResult.Error(messageResId, isAuthError) + } + + private fun VideoPlaysResult.toFetchResult() = when (this) { + is VideoPlaysResult.Success -> StatsCardFetchResult.Success( + items = items.mapIndexed { i, item -> item.toDetailItem(i.toLong()) }, + totalValue = totalViews, + totalValueChange = totalViewsChange, + totalValueChangePercent = totalViewsChangePercent + ) + is VideoPlaysResult.Error -> StatsCardFetchResult.Error(messageResId, isAuthError) + } + + private fun FileDownloadsResult.toFetchResult() = when (this) { + is FileDownloadsResult.Success -> StatsCardFetchResult.Success( + items = items.mapIndexed { i, item -> item.toDetailItem(i.toLong()) }, + totalValue = totalDownloads, + totalValueChange = totalDownloadsChange, + totalValueChangePercent = totalDownloadsChangePercent + ) + is FileDownloadsResult.Error -> StatsCardFetchResult.Error(messageResId, isAuthError) + } + + private fun ClickItemData.toDetailItem(id: Long) = MostViewedDetailItem( + id = id, + title = name, + views = clicks, + change = MostViewedChange.fromChange(clicksChange, clicksChangePercent) + ) + + private fun SearchTermItemData.toDetailItem(id: Long) = MostViewedDetailItem( + id = id, + title = name, + views = views, + change = MostViewedChange.fromChange(viewsChange, viewsChangePercent) + ) + + private fun VideoPlayItemData.toDetailItem(id: Long) = MostViewedDetailItem( + id = id, + title = title, + views = views, + change = MostViewedChange.fromChange(viewsChange, viewsChangePercent) + ) + + private fun FileDownloadItemData.toDetailItem(id: Long) = MostViewedDetailItem( + id = id, + title = name, + views = downloads, + change = MostViewedChange.fromChange(downloadsChange, downloadsChangePercent) + ) +} 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 index ca12429c3428..bfd893fcbe66 100644 --- 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 @@ -5,54 +5,52 @@ 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). + * Backs [MostViewedDetailActivity] when it self-fetches its data. The cards these sources belong to + * only show the first N items, so the detail screen re-requests the full, unbounded list (max = 0) + * itself — via [MostViewedDetailFetcher] — instead of receiving it through the Intent, which could + * exceed the Binder transaction limit for sources with many entries. */ @HiltViewModel class MostViewedDetailViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, - private val statsRepository: StatsRepository, + private val detailFetcher: MostViewedDetailFetcher, private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow(MostViewedDetailUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private var source: MostViewedDetailSource? = null 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. + * Loads the detail data for [source] and [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) { + fun load(source: MostViewedDetailSource, period: StatsPeriod) { + this.source = source this.period = period if (hasStartedLoading) return - load() + fetch() } - fun retry() = load() + fun retry() = fetch() - private fun load() { + private fun fetch() { + val source = source ?: return val period = period ?: return val site = selectedSiteRepository.getSelectedSite() val accessToken = accountStore.accessToken @@ -61,38 +59,37 @@ class MostViewedDetailViewModel @Inject constructor( return } hasStartedLoading = true - statsRepository.init(accessToken) _uiState.value = MostViewedDetailUiState.Loading viewModelScope.launch { - _uiState.value = fetchReferrers(site.siteId, period) + _uiState.value = fetchDetail(source, site.siteId, period, accessToken) } } @Suppress("TooGenericExceptionCaught") - private suspend fun fetchReferrers(siteId: Long, period: StatsPeriod): MostViewedDetailUiState = + private suspend fun fetchDetail( + source: MostViewedDetailSource, + siteId: Long, + period: StatsPeriod, + accessToken: String + ): 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) + when (val result = detailFetcher.fetch(source, siteId, period, accessToken)) { + is StatsCardFetchResult.Success -> MostViewedDetailUiState.Loaded( + items = result.items, + maxViewsForBar = result.items.firstOrNull()?.views ?: 1L, + totalViews = result.totalValue, + totalViewsChange = result.totalValueChange, + totalViewsChangePercent = result.totalValueChangePercent, + dateRange = period.toDateRangeString(resourceProvider) + ) + is StatsCardFetchResult.Error -> MostViewedDetailUiState.Error( + resourceProvider.getString(result.messageResId) ) } - } 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)) + MostViewedDetailUiState.Error( + e.message ?: resourceProvider.getString(R.string.stats_error_unknown) + ) } } 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 index 532fab9e941e..02f31db2ba83 100644 --- 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 @@ -16,10 +16,6 @@ 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 @@ -31,7 +27,7 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { private lateinit var accountStore: AccountStore @Mock - private lateinit var statsRepository: StatsRepository + private lateinit var detailFetcher: MostViewedDetailFetcher @Mock private lateinit var resourceProvider: ResourceProvider @@ -50,20 +46,19 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { 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, + detailFetcher, resourceProvider ) } @Test - fun `when loadReferrers succeeds, then loaded state with items and totals is emitted`() = test { - whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) + fun `when load succeeds, then loaded state with items and totals is emitted`() = test { + whenever(detailFetcher.fetch(any(), any(), any(), any())).thenReturn(createSuccessResult()) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val state = viewModel.uiState.value @@ -79,10 +74,10 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { } @Test - fun `when loadReferrers succeeds, then child items propagate to the loaded state`() = test { - whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) + fun `when load succeeds, then child items propagate to the loaded state`() = test { + whenever(detailFetcher.fetch(any(), any(), any(), any())).thenReturn(createSuccessResult()) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val loaded = viewModel.uiState.value as MostViewedDetailUiState.Loaded @@ -96,34 +91,34 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { 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) + viewModel.load(MostViewedDetailSource.REFERRERS, 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()) + verify(detailFetcher, never()).fetch(any(), any(), 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) + viewModel.load(MostViewedDetailSource.REFERRERS, 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()) + verify(detailFetcher, never()).fetch(any(), any(), 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")) + fun `when the fetcher returns an error, then the api error state is emitted`() = test { + whenever(detailFetcher.fetch(any(), any(), any(), any())) + .thenReturn(StatsCardFetchResult.Error(R.string.stats_error_api)) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val state = viewModel.uiState.value @@ -132,58 +127,57 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { } @Test - fun `when the fetch throws, then the generic unknown error state is emitted`() = test { - whenever(statsRepository.fetchReferrersDetail(any(), any())) + fun `when the fetch throws, then the exception message is surfaced as the error state`() = test { + whenever(detailFetcher.fetch(any(), any(), any(), any())) .thenThrow(RuntimeException(EXCEPTION_MESSAGE)) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val state = viewModel.uiState.value assertThat(state).isInstanceOf(MostViewedDetailUiState.Error::class.java) - assertThat((state as MostViewedDetailUiState.Error).message).isEqualTo(UNKNOWN_ERROR) + assertThat((state as MostViewedDetailUiState.Error).message).isEqualTo(EXCEPTION_MESSAGE) } @Test - fun `when loadReferrers is called twice, then the data is fetched only once`() = test { - whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) + fun `when load is called twice, then the data is fetched only once`() = test { + whenever(detailFetcher.fetch(any(), any(), any(), any())).thenReturn(createSuccessResult()) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() - verify(statsRepository, times(1)).fetchReferrersDetail(any(), any()) + verify(detailFetcher, times(1)).fetch(any(), any(), 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()) + whenever(detailFetcher.fetch(any(), any(), any(), any())).thenReturn(createSuccessResult()) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() viewModel.retry() advanceUntilIdle() - verify(statsRepository, times(2)).fetchReferrersDetail(any(), any()) + verify(detailFetcher, times(2)).fetch(any(), any(), any(), any()) } - private fun createSuccessResult() = MostViewedResult.Success( + private fun createSuccessResult() = StatsCardFetchResult.Success( items = listOf( - MostViewedItemData( + MostViewedDetailItem( id = 1, title = "Search Engines", views = TEST_VIEWS, - previousViews = TEST_PREVIOUS_VIEWS, - isFirst = true, + change = MostViewedChange.NoChange, children = listOf( - MostViewedChildData(name = "Google Search", url = CHILD_URL, views = TEST_VIEWS) + MostViewedChildItem(name = "Google Search", url = CHILD_URL, views = TEST_VIEWS) ) ) ), - totalViews = TEST_TOTAL_VIEWS, - totalViewsChange = TEST_TOTAL_VIEWS_CHANGE, - totalViewsChangePercent = TEST_TOTAL_VIEWS_CHANGE_PERCENT + totalValue = TEST_TOTAL_VIEWS, + totalValueChange = TEST_TOTAL_VIEWS_CHANGE, + totalValueChangePercent = TEST_TOTAL_VIEWS_CHANGE_PERCENT ) companion object { @@ -191,12 +185,10 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { 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/repository/StatsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTest.kt index f0ad9af260b8..72776a094158 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,6 +5,9 @@ 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.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.datasource.StatsUnit @@ -544,6 +547,76 @@ 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 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 successful response, when fetchMostViewed, then totalViews is calculated correctly`() = test { whenever(statsDataSource.fetchTopPostsAndPages(any(), any(), any())) @@ -604,6 +677,21 @@ 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( @@ -712,6 +800,11 @@ 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 { @@ -719,6 +812,10 @@ class StatsRepositoryTest : BaseUnitTest() { private const val TEST_ACCESS_TOKEN = "test_access_token" 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_PERIOD_1 = "2024-01-15" private const val TEST_PERIOD_2 = "2024-01-16" @@ -743,5 +840,10 @@ 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 } }