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..d742bbf09702 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,79 +16,86 @@ 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 import androidx.compose.ui.unit.dp import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R +import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.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() }, + onOpenWpAdmin = { + viewModel.getAdminUrl()?.let { ActivityLauncher.openUrlExternal(this, it) } + } ) } } } + 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 +105,10 @@ 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 = {}, + onOpenWpAdmin: () -> Unit = {} ) { Scaffold( topBar = { @@ -117,44 +125,98 @@ 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( + message = uiState.message, + onRetry = onRetry, + onOpenWpAdmin = if (uiState.isAuthError) onOpenWpAdmin else null, + modifier = 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)) + } + + 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)) + } + } +} - 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, + onOpenWpAdmin: (() -> Unit)? = null +) { + 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)) + if (onOpenWpAdmin != null) { + Button(onClick = onOpenWpAdmin) { + Text(text = stringResource(R.string.my_site_btn_wp_admin)) + } + } else { + Button(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) } } } @@ -181,23 +243,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..e46bc3819c53 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailViewModel.kt @@ -0,0 +1,119 @@ +package org.wordpress.android.ui.newstats.authors + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +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.util.AppLog +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() + + /** + * The WP-Admin URL of the selected site, used to offer a re-authentication action when the + * fetch fails with an auth error (mirrors the authors card and the Most Viewed detail screen). + */ + fun getAdminUrl(): String? = selectedSiteRepository.getSelectedSite()?.adminUrl + + 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( + message = resourceProvider.getString(result.messageResId), + isAuthError = result.isAuthError + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(AppLog.T.STATS, "Error fetching authors detail: ${e.message}", e) + AuthorsDetailUiState.Error(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, + val isAuthError: Boolean = false + ) : 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..372966ec0342 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 @@ -17,8 +17,6 @@ import org.wordpress.android.ui.newstats.repository.TopAuthorItemData import org.wordpress.android.R import org.wordpress.android.ui.newstats.repository.TopAuthorsResult import org.wordpress.android.util.AppLog -import org.wordpress.android.ui.newstats.util.toDateRangeString -import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject import kotlin.math.abs @@ -28,8 +26,7 @@ private const val CARD_MAX_ITEMS = 10 class AuthorsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, - private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider + private val statsRepository: StatsRepository ) : ViewModel() { private val _uiState = MutableStateFlow(AuthorsCardUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -41,11 +38,6 @@ class AuthorsViewModel @Inject constructor( private var loadingPeriod: StatsPeriod? = null private var loadedPeriod: StatsPeriod? = null - private var allAuthors: List = emptyList() - private var cachedTotalViews: Long = 0L - private var cachedTotalViewsChange: Long = 0L - private var cachedTotalViewsChangePercent: Double = 0.0 - fun loadData() { val site = selectedSiteRepository.getSelectedSite() if (site == null) { @@ -107,15 +99,11 @@ class AuthorsViewModel @Inject constructor( loadData() } - fun getDetailData(): AuthorsDetailData { - return AuthorsDetailData( - authors = allAuthors, - totalViews = cachedTotalViews, - totalViewsChange = cachedTotalViewsChange, - totalViewsChangePercent = cachedTotalViewsChangePercent, - dateRange = currentPeriod.toDateRangeString(resourceProvider) - ) - } + /** + * 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 @Suppress("TooGenericExceptionCaught") private suspend fun fetchTopAuthors(site: SiteModel) { @@ -127,29 +115,15 @@ class AuthorsViewModel @Inject constructor( )) { is TopAuthorsResult.Success -> { loadedPeriod = currentPeriod - cachedTotalViews = result.totalViews - cachedTotalViewsChange = result.totalViewsChange - cachedTotalViewsChangePercent = - result.totalViewsChangePercent if (result.authors.isEmpty()) { - allAuthors = emptyList() _uiState.value = AuthorsCardUiState.Loaded( authors = emptyList(), maxViewsForBar = 0, hasMoreItems = false ) } else { - val authors = result.authors.map { author -> - AuthorUiItem( - name = author.name, - avatarUrl = author.avatarUrl, - views = author.views, - change = author.toStatsViewChange() - ) - } - - allAuthors = authors + val authors = result.authors.map { it.toAuthorUiItem() } val cardAuthors = authors.take(CARD_MAX_ITEMS) val maxViewsForBar = @@ -177,20 +151,19 @@ 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 - } - } } -data class AuthorsDetailData( - val authors: List, - val totalViews: Long, - val totalViewsChange: Long, - val totalViewsChangePercent: Double, - val dateRange: String +/** + * 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 + } ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt index 600c2fc99324..85e45d11c4e2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt @@ -3,7 +3,6 @@ package org.wordpress.android.ui.newstats.clicks import dagger.hilt.android.lifecycle.HiltViewModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.newstats.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.BaseStatsCardViewModel import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -25,7 +24,6 @@ class ClicksViewModel @Inject constructor( selectedSiteRepository, accountStore, statsRepository, resourceProvider ) { - override val cardType = StatsCardType.CLICKS override val logTag = "clicks" override suspend fun fetchStats( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModel.kt index 3f931179b22d..2948f6e7c22b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModel.kt @@ -3,7 +3,6 @@ package org.wordpress.android.ui.newstats.filedownloads import dagger.hilt.android.lifecycle.HiltViewModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.newstats.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.BaseStatsCardViewModel import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -25,7 +24,6 @@ class FileDownloadsViewModel @Inject constructor( selectedSiteRepository, accountStore, statsRepository, resourceProvider ) { - override val cardType = StatsCardType.FILE_DOWNLOADS override val logTag = "file downloads" override suspend fun fetchStats( 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..afb0272f8175 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 @@ -12,10 +12,8 @@ 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.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod 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 @@ -26,7 +24,6 @@ private const val CARD_MAX_ITEMS = 10 * the same loading/refresh/period/error handling pattern. * * Subclasses only need to provide: - * - [cardType]: the [StatsCardType] for detail navigation * - [logTag]: a label for error logging * - [fetchStats]: the suspend function that calls the * repository and maps the result to [StatsCardFetchResult] @@ -51,13 +48,6 @@ abstract class BaseStatsCardViewModel( private var loadingPeriod: StatsPeriod? = null private var loadedPeriod: StatsPeriod? = null private var fetchJob: Job? = null - - private var allItems: List = emptyList() - private var cachedTotalValue: Long = 0L - private var cachedTotalValueChange: Long = 0L - private var cachedTotalValueChangePercent: Double = 0.0 - - protected abstract val cardType: StatsCardType protected abstract val logTag: String protected abstract suspend fun fetchStats( @@ -137,19 +127,11 @@ abstract class BaseStatsCardViewModel( loadData() } - fun getDetailData(): MostViewedDetailData { - return MostViewedDetailData( - cardType = cardType, - items = allItems, - totalViews = cachedTotalValue, - totalViewsChange = cachedTotalValueChange, - totalViewsChangePercent = - cachedTotalValueChangePercent, - dateRange = currentPeriod.toDateRangeString( - resourceProvider - ) - ) - } + /** + * 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 @Suppress("TooGenericExceptionCaught") private suspend fun fetchAndProcess(site: SiteModel) { @@ -161,21 +143,14 @@ abstract class BaseStatsCardViewModel( ) { is StatsCardFetchResult.Success -> { loadedPeriod = currentPeriod - cachedTotalValue = result.totalValue - cachedTotalValueChange = - result.totalValueChange - cachedTotalValueChangePercent = - result.totalValueChangePercent if (result.items.isEmpty()) { - allItems = emptyList() _uiState.value = MostViewedCardUiState.Loaded( items = emptyList(), maxViewsForBar = 0 ) } else { - allItems = result.items val cardItems = result.items .take(CARD_MAX_ITEMS) val maxForBar = 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..57a11174a8ff 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 @@ -39,6 +39,7 @@ 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.ActivityLauncher import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity @@ -57,9 +58,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 +79,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() } @@ -96,6 +98,9 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { valueHeaderResId = valueHeaderResId, onBackPressed = onBackPressedDispatcher::onBackPressed, onRetry = { viewModel.retry() }, + onOpenWpAdmin = { + viewModel.getAdminUrl()?.let { ActivityLauncher.openUrlExternal(this, it) } + }, onChildClick = { url -> activityNavigator.openInCustomTab(this, url) } ) } @@ -103,27 +108,32 @@ 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? { - val periodType = intent.getStringExtra(EXTRA_PERIOD_TYPE) ?: return null + private fun readSelfFetchArgs(): SelfFetchArgs? { + val sourceName = intent.getStringExtra(EXTRA_DETAIL_SOURCE) + val periodType = intent.getStringExtra(EXTRA_PERIOD_TYPE) + if (sourceName == null || periodType == null) 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() return MostViewedDetailUiState.Loaded( items = items, // Calculate maxViewsForBar once (list is sorted by views descending) - maxViewsForBar = items.firstOrNull()?.views ?: 1L, + maxViewsForBar = items.firstOrNull()?.views ?: 0L, totalViews = getLongExtra(EXTRA_TOTAL_VIEWS, 0L), totalViewsChange = getLongExtra(EXTRA_TOTAL_VIEWS_CHANGE, 0L), totalViewsChangePercent = getDoubleExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, 0.0), @@ -161,12 +171,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()) @@ -186,6 +205,7 @@ private fun MostViewedDetailScreen( valueHeaderResId: Int = R.string.stats_views, onBackPressed: () -> Unit, onRetry: () -> Unit = {}, + onOpenWpAdmin: () -> Unit = {}, onChildClick: (String) -> Unit = {} ) { val title = stringResource(cardType.displayNameResId) @@ -210,7 +230,12 @@ private fun MostViewedDetailScreen( .padding(contentPadding) when (uiState) { is MostViewedDetailUiState.Loading -> DetailLoadingContent(contentModifier) - is MostViewedDetailUiState.Error -> DetailErrorContent(uiState.message, onRetry, contentModifier) + is MostViewedDetailUiState.Error -> DetailErrorContent( + message = uiState.message, + onRetry = onRetry, + onOpenWpAdmin = if (uiState.isAuthError) onOpenWpAdmin else null, + modifier = contentModifier + ) is MostViewedDetailUiState.Loaded -> DetailLoadedContent( state = uiState, valueHeaderResId = valueHeaderResId, @@ -289,7 +314,8 @@ private fun DetailLoadingContent(modifier: Modifier = Modifier) { private fun DetailErrorContent( message: String, onRetry: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onOpenWpAdmin: (() -> Unit)? = null ) { Column( modifier = modifier.padding(16.dp), @@ -302,8 +328,14 @@ private fun DetailErrorContent( color = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onRetry) { - Text(text = stringResource(R.string.retry)) + if (onOpenWpAdmin != null) { + Button(onClick = onOpenWpAdmin) { + Text(text = stringResource(R.string.my_site_btn_wp_admin)) + } + } else { + Button(onClick = onRetry) { + Text(text = stringResource(R.string.retry)) + } } } } 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..84856a78f88a 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 @@ -12,48 +12,55 @@ 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() { - val period = period ?: return + /** + * The WP-Admin URL of the selected site, used to offer a re-authentication action when the + * fetch fails with an auth error (mirrors the cards and the UTM detail screen). + */ + fun getAdminUrl(): String? = selectedSiteRepository.getSelectedSite()?.adminUrl + + private fun fetch() { + val source = source + val period = period + if (source == null || period == null) return val site = selectedSiteRepository.getSelectedSite() val accessToken = accountStore.accessToken if (site == null || accessToken.isNullOrEmpty()) { @@ -61,31 +68,32 @@ 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 ?: 0L, + totalViews = result.totalValue, + totalViewsChange = result.totalValueChange, + totalViewsChangePercent = result.totalValueChangePercent, + dateRange = period.toDateRangeString(resourceProvider) + ) + is StatsCardFetchResult.Error -> MostViewedDetailUiState.Error( + message = resourceProvider.getString(result.messageResId), + isAuthError = result.isAuthError ) } } catch (e: CancellationException) { @@ -108,5 +116,8 @@ sealed interface MostViewedDetailUiState { val dateRange: String ) : MostViewedDetailUiState - data class Error(val message: String) : MostViewedDetailUiState + data class Error( + val message: String, + val isAuthError: Boolean = false + ) : MostViewedDetailUiState } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModel.kt index e7cbc6568818..9adf52e851ad 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModel.kt @@ -3,7 +3,6 @@ package org.wordpress.android.ui.newstats.searchterms import dagger.hilt.android.lifecycle.HiltViewModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.newstats.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.BaseStatsCardViewModel import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -25,7 +24,6 @@ class SearchTermsViewModel @Inject constructor( selectedSiteRepository, accountStore, statsRepository, resourceProvider ) { - override val cardType = StatsCardType.SEARCH_TERMS override val logTag = "search terms" override suspend fun fetchStats( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModel.kt index 7a0720d0558c..e5b928e73128 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModel.kt @@ -3,7 +3,6 @@ package org.wordpress.android.ui.newstats.videoplays import dagger.hilt.android.lifecycle.HiltViewModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.newstats.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.BaseStatsCardViewModel import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -25,7 +24,6 @@ class VideoPlaysViewModel @Inject constructor( selectedSiteRepository, accountStore, statsRepository, resourceProvider ) { - override val cardType = StatsCardType.VIDEO_PLAYS override val logTag = "video plays" override suspend fun fetchStats( diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailViewModelTest.kt new file mode 100644 index 000000000000..0b322f6beff2 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailViewModelTest.kt @@ -0,0 +1,230 @@ +package org.wordpress.android.ui.newstats.authors + +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.StatsRepository +import org.wordpress.android.ui.newstats.repository.TopAuthorItemData +import org.wordpress.android.ui.newstats.repository.TopAuthorsResult +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class AuthorsDetailViewModelTest : 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: AuthorsDetailViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + adminUrl = TEST_ADMIN_URL + } + + @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 = AuthorsDetailViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + } + + @Test + fun `when load succeeds, then loaded state with mapped authors and totals is emitted`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())).thenReturn(createSuccessResult()) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthorsDetailUiState.Loaded::class.java) + val loaded = state as AuthorsDetailUiState.Loaded + assertThat(loaded.authors).hasSize(2) + assertThat(loaded.authors[0].name).isEqualTo("John Doe") + assertThat(loaded.authors[0].views).isEqualTo(TOP_AUTHOR_VIEWS) + assertThat(loaded.authors[1].name).isEqualTo("Jane Smith") + 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 load succeeds, then maxViewsForBar is the first (highest) author's views`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())).thenReturn(createSuccessResult()) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val loaded = viewModel.uiState.value as AuthorsDetailUiState.Loaded + assertThat(loaded.maxViewsForBar).isEqualTo(TOP_AUTHOR_VIEWS) + } + + @Test + fun `when the success result has no authors, then maxViewsForBar is zero`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())).thenReturn( + TopAuthorsResult.Success( + authors = emptyList(), + totalViews = 0L, + totalViewsChange = 0L, + totalViewsChangePercent = 0.0 + ) + ) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val loaded = viewModel.uiState.value as AuthorsDetailUiState.Loaded + assertThat(loaded.authors).isEmpty() + assertThat(loaded.maxViewsForBar).isEqualTo(0L) + } + + @Test + fun `when no site is selected, then error state is emitted and no fetch happens`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthorsDetailUiState.Error::class.java) + assertThat((state as AuthorsDetailUiState.Error).message).isEqualTo(API_ERROR) + verify(statsRepository, never()).fetchTopAuthors(any(), any()) + } + + @Test + fun `when access token is empty, then error state is emitted and no fetch happens`() = test { + whenever(accountStore.accessToken).thenReturn("") + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthorsDetailUiState.Error::class.java) + assertThat((state as AuthorsDetailUiState.Error).message).isEqualTo(API_ERROR) + verify(statsRepository, never()).fetchTopAuthors(any(), any()) + } + + @Test + fun `when the repository returns an error, then its message resId is resolved into the error state`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())) + .thenReturn(TopAuthorsResult.Error(R.string.stats_error_api)) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthorsDetailUiState.Error::class.java) + assertThat((state as AuthorsDetailUiState.Error).message).isEqualTo(API_ERROR) + } + + @Test + fun `when the repository returns an auth error, then the error state carries the auth flag`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())) + .thenReturn(TopAuthorsResult.Error(R.string.stats_error_api, isAuthError = true)) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value as AuthorsDetailUiState.Error + assertThat(state.message).isEqualTo(API_ERROR) + assertThat(state.isAuthError).isTrue() + } + + @Test + fun `when getAdminUrl is called, then the selected site's admin url is returned`() { + assertThat(viewModel.getAdminUrl()).isEqualTo(TEST_ADMIN_URL) + } + + @Test + fun `when the fetch throws, then the generic unknown error state is emitted`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())) + .thenThrow(RuntimeException(EXCEPTION_MESSAGE)) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AuthorsDetailUiState.Error::class.java) + assertThat((state as AuthorsDetailUiState.Error).message).isEqualTo(UNKNOWN_ERROR) + } + + @Test + fun `when load is called twice, then the data is fetched only once`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())).thenReturn(createSuccessResult()) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + + verify(statsRepository, times(1)).fetchTopAuthors(any(), any()) + } + + @Test + fun `when retry is called after a load, then the data is fetched again`() = test { + whenever(statsRepository.fetchTopAuthors(any(), any())).thenReturn(createSuccessResult()) + + viewModel.load(StatsPeriod.Last7Days) + advanceUntilIdle() + viewModel.retry() + advanceUntilIdle() + + verify(statsRepository, times(2)).fetchTopAuthors(any(), any()) + } + + private fun createSuccessResult() = TopAuthorsResult.Success( + authors = listOf( + TopAuthorItemData(name = "John Doe", avatarUrl = null, views = TOP_AUTHOR_VIEWS, previousViews = 100L), + TopAuthorItemData(name = "Jane Smith", avatarUrl = null, views = 200L, previousViews = 220L) + ), + 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 TEST_ADMIN_URL = "https://example.com/wp-admin" + 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 TOP_AUTHOR_VIEWS = 500L + private const val TEST_TOTAL_VIEWS = 700L + private const val TEST_TOTAL_VIEWS_CHANGE = 60L + private const val TEST_TOTAL_VIEWS_CHANGE_PERCENT = 9.4 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt index 262047c06372..bb8a0fb33b27 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt @@ -20,7 +20,6 @@ import org.wordpress.android.ui.newstats.components.StatsViewChange import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.TopAuthorItemData import org.wordpress.android.ui.newstats.repository.TopAuthorsResult -import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi class AuthorsViewModelTest : BaseUnitTest() { @@ -33,9 +32,6 @@ class AuthorsViewModelTest : BaseUnitTest() { @Mock private lateinit var statsRepository: StatsRepository - @Mock - private lateinit var resourceProvider: ResourceProvider - private lateinit var viewModel: AuthorsViewModel private val testSite = SiteModel().apply { @@ -54,8 +50,7 @@ class AuthorsViewModelTest : BaseUnitTest() { viewModel = AuthorsViewModel( selectedSiteRepository, accountStore, - statsRepository, - resourceProvider + statsRepository ) viewModel.onPeriodChanged(StatsPeriod.Last7Days) } @@ -343,57 +338,6 @@ class AuthorsViewModelTest : BaseUnitTest() { } // endregion - // region getDetailData - @Test - fun `when getDetailData is called, then returns cached data`() = test { - whenever(statsRepository.fetchTopAuthors(any(), any())) - .thenReturn(createSuccessResult()) - whenever(resourceProvider.getString(R.string.stats_period_last_7_days)) - .thenReturn("Last 7 days") - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - - assertThat(detailData.authors).hasSize(2) - assertThat(detailData.totalViews).isEqualTo(TEST_TOTAL_VIEWS) - assertThat(detailData.totalViewsChange).isEqualTo(TEST_TOTAL_VIEWS_CHANGE) - assertThat(detailData.totalViewsChangePercent).isEqualTo(TEST_TOTAL_VIEWS_CHANGE_PERCENT) - assertThat(detailData.dateRange).isEqualTo("Last 7 days") - } - - @Test - fun `when getDetailData is called, then all authors are returned not just card items`() = test { - val manyAuthors = (1..15).map { index -> - TopAuthorItemData( - name = "Author $index", - avatarUrl = "https://example.com/avatar$index.jpg", - views = (100 - index).toLong(), - previousViews = (90 - index).toLong() - ) - } - whenever(resourceProvider.getString(R.string.stats_period_last_7_days)) - .thenReturn("Last 7 days") - whenever(statsRepository.fetchTopAuthors(any(), any())) - .thenReturn( - TopAuthorsResult.Success( - authors = manyAuthors, - totalViews = 1000, - totalViewsChange = 100, - totalViewsChangePercent = 10.0 - ) - ) - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - // Card shows max 10, but detail data should have all 15 - assertThat(detailData.authors).hasSize(15) - } - // endregion - // region Change calculations @Test fun `when author has positive change, then StatsViewChange_Positive is returned`() = test { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModelTest.kt index f02376309e38..bde4a16bfa38 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModelTest.kt @@ -15,7 +15,6 @@ 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.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.MostViewedCardUiState import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -297,67 +296,6 @@ class ClicksViewModelTest : BaseUnitTest() { } // endregion - // region getDetailData - @Test - fun `when getDetailData is called, then returns cached data`() = - test { - whenever(statsRepository.fetchClicks(any(), any())) - .thenReturn(createSuccessResult()) - whenever( - resourceProvider.getString( - R.string.stats_period_last_7_days - ) - ).thenReturn("Last 7 days") - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - - assertThat(detailData.cardType) - .isEqualTo(StatsCardType.CLICKS) - assertThat(detailData.items).hasSize(2) - assertThat(detailData.totalViews) - .isEqualTo(TEST_TOTAL_CLICKS) - assertThat(detailData.totalViewsChange) - .isEqualTo(TEST_TOTAL_CLICKS_CHANGE) - assertThat(detailData.totalViewsChangePercent) - .isEqualTo(TEST_TOTAL_CLICKS_CHANGE_PERCENT) - } - - @Test - fun `when getDetailData called, then all items returned not just card items`() = - test { - val manyItems = (1..15).map { index -> - ClickItemData( - name = "Link $index", - clicks = (100 - index).toLong(), - previousClicks = (90 - index).toLong() - ) - } - whenever( - resourceProvider.getString( - R.string.stats_period_last_7_days - ) - ).thenReturn("Last 7 days") - whenever(statsRepository.fetchClicks(any(), any())) - .thenReturn( - ClicksResult.Success( - items = manyItems, - totalClicks = 1000, - totalClicksChange = 100, - totalClicksChangePercent = 10.0 - ) - ) - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - assertThat(detailData.items).hasSize(15) - } - // endregion - // region Change calculations @Test fun `when item has positive change, then Positive is returned`() = diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModelTest.kt index 1b492d34278f..0e4c6ccccbf2 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/filedownloads/FileDownloadsViewModelTest.kt @@ -15,7 +15,6 @@ 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.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.MostViewedCardUiState import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -269,33 +268,6 @@ class FileDownloadsViewModelTest : BaseUnitTest() { } // endregion - // region getDetailData - @Test - fun `when getDetailData is called, then returns cached data`() = - test { - whenever(statsRepository.fetchFileDownloads(any(), any())) - .thenReturn(createSuccessResult()) - whenever( - resourceProvider.getString( - R.string.stats_period_last_7_days - ) - ).thenReturn("Last 7 days") - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - - assertThat(detailData.cardType) - .isEqualTo(StatsCardType.FILE_DOWNLOADS) - assertThat(detailData.items).hasSize(2) - assertThat(detailData.totalViews) - .isEqualTo(TEST_TOTAL_DOWNLOADS) - assertThat(detailData.totalViewsChange) - .isEqualTo(TEST_TOTAL_DOWNLOADS_CHANGE) - } - // endregion - // region Change calculations @Test fun `when item has positive change, then Positive is returned`() = diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcherTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcherTest.kt new file mode 100644 index 000000000000..57f5d6da6220 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcherTest.kt @@ -0,0 +1,241 @@ +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.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +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.MostViewedItemData +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 + +@ExperimentalCoroutinesApi +class MostViewedDetailFetcherTest : BaseUnitTest() { + @Mock + private lateinit var statsRepository: StatsRepository + + private lateinit var fetcher: MostViewedDetailFetcher + + @Before + fun setUp() { + fetcher = MostViewedDetailFetcher(statsRepository) + } + + @Test + fun `fetch initializes the repository with the access token`() = test { + whenever(statsRepository.fetchClicks(any(), any())).thenReturn(emptyClicksSuccess()) + + fetcher.fetch(MostViewedDetailSource.CLICKS, SITE_ID, PERIOD, ACCESS_TOKEN) + + verify(statsRepository).init(ACCESS_TOKEN) + } + + // region Referrers + @Test + fun `fetch referrers maps the success result into the common shape`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn( + MostViewedResult.Success( + items = listOf( + MostViewedItemData( + id = 7, + title = "Search Engines", + views = 30L, + previousViews = 10L, + isFirst = true + ) + ), + totalViews = 100L, + totalViewsChange = 40L, + totalViewsChangePercent = 66.6 + ) + ) + + val result = fetcher.fetch(MostViewedDetailSource.REFERRERS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val success = result as StatsCardFetchResult.Success + assertThat(success.items).hasSize(1) + assertThat(success.items[0].id).isEqualTo(7L) + assertThat(success.items[0].title).isEqualTo("Search Engines") + assertThat(success.items[0].views).isEqualTo(30L) + assertThat(success.totalValue).isEqualTo(100L) + assertThat(success.totalValueChange).isEqualTo(40L) + assertThat(success.totalValueChangePercent).isEqualTo(66.6) + } + + @Test + fun `fetch referrers maps the error result to the generic api error`() = test { + whenever(statsRepository.fetchReferrersDetail(any(), any())) + .thenReturn(MostViewedResult.Error("network")) + + val result = fetcher.fetch(MostViewedDetailSource.REFERRERS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val error = result as StatsCardFetchResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_api) + assertThat(error.isAuthError).isFalse() + } + // endregion + + // region Clicks + @Test + fun `fetch clicks maps items with positional ids and clicks totals`() = test { + whenever(statsRepository.fetchClicks(any(), any())).thenReturn( + ClicksResult.Success( + items = listOf( + ClickItemData(name = "example.com", clicks = 50L, previousClicks = 20L), + ClickItemData(name = "other.com", clicks = 10L, previousClicks = 10L) + ), + totalClicks = 60L, + totalClicksChange = 30L, + totalClicksChangePercent = 100.0 + ) + ) + + val result = fetcher.fetch(MostViewedDetailSource.CLICKS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val success = result as StatsCardFetchResult.Success + assertThat(success.items).hasSize(2) + assertThat(success.items[0].id).isEqualTo(0L) + assertThat(success.items[0].title).isEqualTo("example.com") + assertThat(success.items[0].views).isEqualTo(50L) + assertThat(success.items[1].id).isEqualTo(1L) + assertThat(success.totalValue).isEqualTo(60L) + assertThat(success.totalValueChange).isEqualTo(30L) + assertThat(success.totalValueChangePercent).isEqualTo(100.0) + } + + @Test + fun `fetch clicks passes through the error message resId and auth flag`() = test { + whenever(statsRepository.fetchClicks(any(), any())) + .thenReturn(ClicksResult.Error(R.string.stats_error_unknown, isAuthError = true)) + + val result = fetcher.fetch(MostViewedDetailSource.CLICKS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val error = result as StatsCardFetchResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_unknown) + assertThat(error.isAuthError).isTrue() + } + // endregion + + // region Search terms + @Test + fun `fetch search terms maps items and totals`() = test { + whenever(statsRepository.fetchSearchTerms(any(), any())).thenReturn( + SearchTermsResult.Success( + items = listOf(SearchTermItemData(name = "kotlin", views = 12L, previousViews = 4L)), + totalViews = 12L, + totalViewsChange = 8L, + totalViewsChangePercent = 200.0 + ) + ) + + val result = fetcher.fetch(MostViewedDetailSource.SEARCH_TERMS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val success = result as StatsCardFetchResult.Success + assertThat(success.items[0].id).isEqualTo(0L) + assertThat(success.items[0].title).isEqualTo("kotlin") + assertThat(success.items[0].views).isEqualTo(12L) + assertThat(success.totalValue).isEqualTo(12L) + } + + @Test + fun `fetch search terms passes through the error`() = test { + whenever(statsRepository.fetchSearchTerms(any(), any())) + .thenReturn(SearchTermsResult.Error(R.string.stats_error_api)) + + val error = fetcher.fetch(MostViewedDetailSource.SEARCH_TERMS, SITE_ID, PERIOD, ACCESS_TOKEN) + as StatsCardFetchResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_api) + } + // endregion + + // region Video plays + @Test + fun `fetch video plays maps the title field and totals`() = test { + whenever(statsRepository.fetchVideoPlays(any(), any())).thenReturn( + VideoPlaysResult.Success( + items = listOf(VideoPlayItemData(title = "Intro.mp4", views = 9L, previousViews = 3L)), + totalViews = 9L, + totalViewsChange = 6L, + totalViewsChangePercent = 200.0 + ) + ) + + val result = fetcher.fetch(MostViewedDetailSource.VIDEO_PLAYS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val success = result as StatsCardFetchResult.Success + assertThat(success.items[0].title).isEqualTo("Intro.mp4") + assertThat(success.items[0].views).isEqualTo(9L) + assertThat(success.totalValue).isEqualTo(9L) + } + + @Test + fun `fetch video plays passes through the error`() = test { + whenever(statsRepository.fetchVideoPlays(any(), any())) + .thenReturn(VideoPlaysResult.Error(R.string.stats_error_api, isAuthError = true)) + + val error = fetcher.fetch(MostViewedDetailSource.VIDEO_PLAYS, SITE_ID, PERIOD, ACCESS_TOKEN) + as StatsCardFetchResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_api) + assertThat(error.isAuthError).isTrue() + } + // endregion + + // region File downloads + @Test + fun `fetch file downloads maps downloads into views and totals`() = test { + whenever(statsRepository.fetchFileDownloads(any(), any())).thenReturn( + FileDownloadsResult.Success( + items = listOf(FileDownloadItemData(name = "guide.pdf", downloads = 15L, previousDownloads = 5L)), + totalDownloads = 15L, + totalDownloadsChange = 10L, + totalDownloadsChangePercent = 200.0 + ) + ) + + val result = fetcher.fetch(MostViewedDetailSource.FILE_DOWNLOADS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val success = result as StatsCardFetchResult.Success + assertThat(success.items[0].title).isEqualTo("guide.pdf") + assertThat(success.items[0].views).isEqualTo(15L) + assertThat(success.totalValue).isEqualTo(15L) + assertThat(success.totalValueChange).isEqualTo(10L) + } + + @Test + fun `fetch file downloads passes through the error`() = test { + whenever(statsRepository.fetchFileDownloads(any(), any())) + .thenReturn(FileDownloadsResult.Error(R.string.stats_error_unknown)) + + val error = fetcher.fetch(MostViewedDetailSource.FILE_DOWNLOADS, SITE_ID, PERIOD, ACCESS_TOKEN) + as StatsCardFetchResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_unknown) + } + // endregion + + private fun emptyClicksSuccess() = ClicksResult.Success( + items = emptyList(), + totalClicks = 0L, + totalClicksChange = 0L, + totalClicksChangePercent = 0.0 + ) + + companion object { + private const val SITE_ID = 123L + private const val ACCESS_TOKEN = "test_access_token" + private val PERIOD = StatsPeriod.Last7Days + } +} 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..68491c255d44 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 @@ -36,12 +36,14 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { @Mock private lateinit var resourceProvider: ResourceProvider + private lateinit var detailFetcher: MostViewedDetailFetcher private lateinit var viewModel: MostViewedDetailViewModel private val testSite = SiteModel().apply { id = 1 siteId = TEST_SITE_ID name = "Test Site" + adminUrl = TEST_ADMIN_URL } @Before @@ -51,19 +53,20 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { 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) + detailFetcher = MostViewedDetailFetcher(statsRepository) viewModel = MostViewedDetailViewModel( selectedSiteRepository, accountStore, - statsRepository, + detailFetcher, resourceProvider ) } @Test - fun `when loadReferrers succeeds, then loaded state with items and totals is emitted`() = test { + fun `when load succeeds, then loaded state with items and totals is emitted`() = test { whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val state = viewModel.uiState.value @@ -79,10 +82,10 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { } @Test - fun `when loadReferrers succeeds, then child items propagate to the loaded state`() = test { + fun `when load succeeds, then child items propagate to the loaded state`() = test { whenever(statsRepository.fetchReferrersDetail(any(), any())).thenReturn(createSuccessResult()) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val loaded = viewModel.uiState.value as MostViewedDetailUiState.Loaded @@ -92,11 +95,38 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { assertThat(loaded.items[0].children[0].views).isEqualTo(TEST_VIEWS) } + @Test + fun `when the source is clicks, then the clicks fetch backs the loaded state`() = test { + whenever(statsRepository.fetchClicks(any(), any())).thenReturn( + org.wordpress.android.ui.newstats.repository.ClicksResult.Success( + items = listOf( + org.wordpress.android.ui.newstats.repository.ClickItemData( + name = "example.com", + clicks = TEST_VIEWS, + previousClicks = TEST_PREVIOUS_VIEWS + ) + ), + totalClicks = TEST_TOTAL_VIEWS, + totalClicksChange = TEST_TOTAL_VIEWS_CHANGE, + totalClicksChangePercent = TEST_TOTAL_VIEWS_CHANGE_PERCENT + ) + ) + + viewModel.load(MostViewedDetailSource.CLICKS, StatsPeriod.Last7Days) + advanceUntilIdle() + + val loaded = viewModel.uiState.value as MostViewedDetailUiState.Loaded + assertThat(loaded.items).hasSize(1) + assertThat(loaded.items[0].title).isEqualTo("example.com") + assertThat(loaded.totalViews).isEqualTo(TEST_TOTAL_VIEWS) + verify(statsRepository, never()).fetchReferrersDetail(any(), any()) + } + @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) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val state = viewModel.uiState.value @@ -109,7 +139,7 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { 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 @@ -123,7 +153,7 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { whenever(statsRepository.fetchReferrersDetail(any(), any())) .thenReturn(MostViewedResult.Error("Network error")) - viewModel.loadReferrers(StatsPeriod.Last7Days) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val state = viewModel.uiState.value @@ -131,12 +161,34 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { assertThat((state as MostViewedDetailUiState.Error).message).isEqualTo(API_ERROR) } + @Test + fun `when the fetch returns an auth error, then the error state carries the auth flag`() = test { + whenever(statsRepository.fetchClicks(any(), any())).thenReturn( + org.wordpress.android.ui.newstats.repository.ClicksResult.Error( + R.string.stats_error_api, + isAuthError = true + ) + ) + + viewModel.load(MostViewedDetailSource.CLICKS, StatsPeriod.Last7Days) + advanceUntilIdle() + + val state = viewModel.uiState.value as MostViewedDetailUiState.Error + assertThat(state.message).isEqualTo(API_ERROR) + assertThat(state.isAuthError).isTrue() + } + + @Test + fun `when getAdminUrl is called, then the selected site's admin url is returned`() { + assertThat(viewModel.getAdminUrl()).isEqualTo(TEST_ADMIN_URL) + } + @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) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() val state = viewModel.uiState.value @@ -145,12 +197,12 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { } @Test - fun `when loadReferrers is called twice, then the data is fetched only once`() = test { + fun `when load is called twice, then the data is fetched only once`() = test { whenever(statsRepository.fetchReferrersDetail(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()) @@ -160,7 +212,7 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { 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) + viewModel.load(MostViewedDetailSource.REFERRERS, StatsPeriod.Last7Days) advanceUntilIdle() viewModel.retry() advanceUntilIdle() @@ -189,6 +241,7 @@ class MostViewedDetailViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val TEST_ADMIN_URL = "https://example.com/wp-admin" 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" diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModelTest.kt index 405c8af0c2ef..5cfd5d0f4fce 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/searchterms/SearchTermsViewModelTest.kt @@ -15,7 +15,6 @@ 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.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.MostViewedCardUiState import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -267,33 +266,6 @@ class SearchTermsViewModelTest : BaseUnitTest() { } // endregion - // region getDetailData - @Test - fun `when getDetailData is called, then returns cached data`() = - test { - whenever(statsRepository.fetchSearchTerms(any(), any())) - .thenReturn(createSuccessResult()) - whenever( - resourceProvider.getString( - R.string.stats_period_last_7_days - ) - ).thenReturn("Last 7 days") - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - - assertThat(detailData.cardType) - .isEqualTo(StatsCardType.SEARCH_TERMS) - assertThat(detailData.items).hasSize(2) - assertThat(detailData.totalViews) - .isEqualTo(TEST_TOTAL_VIEWS) - assertThat(detailData.totalViewsChange) - .isEqualTo(TEST_TOTAL_VIEWS_CHANGE) - } - // endregion - // region Change calculations @Test fun `when item has positive change, then Positive is returned`() = diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModelTest.kt index 9c2979b0c626..072d1c4959da 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/videoplays/VideoPlaysViewModelTest.kt @@ -15,7 +15,6 @@ 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.StatsCardType import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.MostViewedCardUiState import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange @@ -267,33 +266,6 @@ class VideoPlaysViewModelTest : BaseUnitTest() { } // endregion - // region getDetailData - @Test - fun `when getDetailData is called, then returns cached data`() = - test { - whenever(statsRepository.fetchVideoPlays(any(), any())) - .thenReturn(createSuccessResult()) - whenever( - resourceProvider.getString( - R.string.stats_period_last_7_days - ) - ).thenReturn("Last 7 days") - - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - - assertThat(detailData.cardType) - .isEqualTo(StatsCardType.VIDEO_PLAYS) - assertThat(detailData.items).hasSize(2) - assertThat(detailData.totalViews) - .isEqualTo(TEST_TOTAL_VIEWS) - assertThat(detailData.totalViewsChange) - .isEqualTo(TEST_TOTAL_VIEWS_CHANGE) - } - // endregion - // region Change calculations @Test fun `when item has positive change, then Positive is returned`() =