diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt index 2b3791373734..3ce8230f8a09 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt @@ -6,11 +6,22 @@ import org.wordpress.android.R enum class InsightsCardType( @StringRes val displayNameResId: Int ) { - YEAR_IN_REVIEW(R.string.stats_insights_year_in_review); + YEAR_IN_REVIEW( + R.string.stats_insights_year_in_review + ), + ALL_TIME_STATS( + R.string.stats_insights_all_time_stats_title + ), + MOST_POPULAR_DAY( + R.string.stats_insights_most_popular_day + ); companion object { - fun defaultCards(): List = listOf( - YEAR_IN_REVIEW - ) + fun defaultCards(): List = + listOf( + YEAR_IN_REVIEW, + ALL_TIME_STATS, + MOST_POPULAR_DAY + ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardsConfiguration.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardsConfiguration.kt index 67b6d9a171a1..7f40cecd33d7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardsConfiguration.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardsConfiguration.kt @@ -2,9 +2,10 @@ package org.wordpress.android.ui.newstats data class InsightsCardsConfiguration( val visibleCards: List = - InsightsCardType.defaultCards() + InsightsCardType.defaultCards(), + val hiddenCards: List = emptyList() ) { - fun hiddenCards(): List { + fun computeHiddenCards(): List { return InsightsCardType.entries .filter { it !in visibleCards } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt index 54bc5a7cd0c8..f993b6736c1f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt @@ -15,7 +15,8 @@ import javax.inject.Inject @HiltViewModel class InsightsViewModel @Inject constructor( - private val selectedSiteRepository: SelectedSiteRepository, + private val selectedSiteRepository: + SelectedSiteRepository, private val cardConfigurationRepository: InsightsCardsConfigurationRepository, private val networkUtilsWrapper: NetworkUtilsWrapper @@ -52,7 +53,8 @@ class InsightsViewModel @Inject constructor( } fun checkNetworkStatus(): Boolean { - val isAvailable = networkUtilsWrapper.isNetworkAvailable() + val isAvailable = + networkUtilsWrapper.isNetworkAvailable() _isNetworkAvailable.value = isAvailable return isAvailable } @@ -91,7 +93,7 @@ class InsightsViewModel @Inject constructor( config: InsightsCardsConfiguration ) { _visibleCards.value = config.visibleCards - _hiddenCards.value = config.hiddenCards() + _hiddenCards.value = config.computeHiddenCards() _cardsToLoad.value = config.visibleCards } 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 17f69fb5fed4..b2930df88602 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 @@ -99,6 +99,10 @@ import org.wordpress.android.ui.newstats.videoplays.VideoPlaysViewModel import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsViewModel import android.widget.Toast +import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsCard +import org.wordpress.android.ui.newstats.alltimestats.AllTimeStatsViewModel +import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayCard +import org.wordpress.android.ui.newstats.mostpopularday.MostPopularDayViewModel import org.wordpress.android.ui.newstats.yearinreview.YearInReviewCard import org.wordpress.android.ui.newstats.yearinreview.YearInReviewDetailActivity import org.wordpress.android.ui.newstats.yearinreview.YearInReviewViewModel @@ -874,16 +878,33 @@ private fun List.dispatchToVisibleCards( @OptIn(ExperimentalMaterial3Api::class) @Composable -@Suppress("LongMethod") +@Suppress("LongMethod", "LongParameterList") private fun InsightsTabContent( - yearInReviewViewModel: YearInReviewViewModel = viewModel(), + yearInReviewViewModel: YearInReviewViewModel = + viewModel(), + allTimeStatsViewModel: AllTimeStatsViewModel = + viewModel(), + mostPopularDayViewModel: MostPopularDayViewModel = + viewModel(), insightsViewModel: InsightsViewModel = viewModel() ) { val context = LocalContext.current val yearInReviewUiState by yearInReviewViewModel .uiState.collectAsState() - val isRefreshing by yearInReviewViewModel + val allTimeStatsUiState by allTimeStatsViewModel + .uiState.collectAsState() + val mostPopularDayUiState by + mostPopularDayViewModel + .uiState.collectAsState() + val yearRefreshing by yearInReviewViewModel .isRefreshing.collectAsState() + val allTimeRefreshing by allTimeStatsViewModel + .isRefreshing.collectAsState() + val popularDayRefreshing by + mostPopularDayViewModel + .isRefreshing.collectAsState() + val isRefreshing = yearRefreshing || + allTimeRefreshing || popularDayRefreshing val pullToRefreshState = rememberPullToRefreshState() val visibleCards by insightsViewModel @@ -900,7 +921,16 @@ private fun InsightsTabContent( LaunchedEffect(cardsToLoad) { cardsToLoad.dispatchInsightsToVisibleCards( onYearInReview = { - yearInReviewViewModel.loadDataIfNeeded() + yearInReviewViewModel + .loadDataIfNeeded() + }, + onAllTimeStats = { + allTimeStatsViewModel + .loadDataIfNeeded() + }, + onMostPopularDay = { + mostPopularDayViewModel + .loadDataIfNeeded() } ) } @@ -922,7 +952,15 @@ private fun InsightsTabContent( val loadVisibleCards = { visibleCards.dispatchInsightsToVisibleCards( - onYearInReview = { yearInReviewViewModel.loadData() } + onYearInReview = { + yearInReviewViewModel.loadData() + }, + onAllTimeStats = { + allTimeStatsViewModel.loadData() + }, + onMostPopularDay = { + mostPopularDayViewModel.loadData() + } ) } @@ -960,6 +998,12 @@ private fun InsightsTabContent( visibleCards.dispatchInsightsToVisibleCards( onYearInReview = { yearInReviewViewModel.refresh() + }, + onAllTimeStats = { + allTimeStatsViewModel.refresh() + }, + onMostPopularDay = { + mostPopularDayViewModel.refresh() } ) }, @@ -1009,6 +1053,78 @@ private fun InsightsTabContent( visibleCards.forEachIndexed { index, cardType -> val cardPosition = cardPositions[index] when (cardType) { + InsightsCardType.ALL_TIME_STATS -> + AllTimeStatsCard( + uiState = allTimeStatsUiState, + onRemoveCard = { + insightsViewModel + .removeCard(cardType) + }, + onRetry = { + allTimeStatsViewModel + .onRetry() + }, + cardPosition = cardPosition, + onMoveUp = { + insightsViewModel + .moveCardUp(cardType) + }, + onMoveToTop = { + insightsViewModel + .moveCardToTop(cardType) + }, + onMoveDown = { + insightsViewModel + .moveCardDown(cardType) + }, + onMoveToBottom = { + insightsViewModel + .moveCardToBottom( + cardType + ) + } + ) + InsightsCardType.MOST_POPULAR_DAY -> + MostPopularDayCard( + uiState = + mostPopularDayUiState, + onRemoveCard = { + insightsViewModel + .removeCard( + cardType + ) + }, + onRetry = { + mostPopularDayViewModel + .onRetry() + }, + cardPosition = + cardPosition, + onMoveUp = { + insightsViewModel + .moveCardUp( + cardType + ) + }, + onMoveToTop = { + insightsViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + insightsViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + insightsViewModel + .moveCardToBottom( + cardType + ) + } + ) InsightsCardType.YEAR_IN_REVIEW -> YearInReviewCard( uiState = yearInReviewUiState, @@ -1053,11 +1169,19 @@ private fun InsightsTabContent( } private fun List.dispatchInsightsToVisibleCards( - onYearInReview: () -> Unit + onYearInReview: () -> Unit, + onAllTimeStats: () -> Unit, + onMostPopularDay: () -> Unit ) { if (InsightsCardType.YEAR_IN_REVIEW in this) { onYearInReview() } + if (InsightsCardType.ALL_TIME_STATS in this) { + onAllTimeStats() + } + if (InsightsCardType.MOST_POPULAR_DAY in this) { + onMostPopularDay() + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCard.kt new file mode 100644 index 000000000000..ca8699adc12b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCard.kt @@ -0,0 +1,362 @@ +package org.wordpress.android.ui.newstats.alltimestats + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Article +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.outlined.People +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.newstats.components.CardPosition +import org.wordpress.android.ui.newstats.components.StatsCardMenu +import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.ui.newstats.util.rememberShimmerBrush + +private val CardCornerRadius = 10.dp +private val CardPadding = 16.dp +private val CardMargin = 16.dp + +@Composable +@Suppress("LongParameterList") +fun AllTimeStatsCard( + uiState: AllTimeStatsCardUiState, + onRemoveCard: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + val borderColor = + MaterialTheme.colorScheme.outlineVariant + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = CardMargin, vertical = 8.dp) + .clip(RoundedCornerShape(CardCornerRadius)) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(CardCornerRadius) + ) + .background(MaterialTheme.colorScheme.surface) + ) { + when (uiState) { + is AllTimeStatsCardUiState.Loading -> + LoadingContent() + is AllTimeStatsCardUiState.Loaded -> + LoadedContent( + uiState, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is AllTimeStatsCardUiState.Error -> + ErrorContent( + uiState, + onRemoveCard, + onRetry, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + val shimmerBrush = rememberShimmerBrush() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + // Title shimmer + Box( + modifier = Modifier + .width(140.dp) + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(16.dp)) + // 4 row shimmers + repeat(4) { index -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = + Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier + .width(80.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .width(60.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + } + if (index < 3) { + Spacer(modifier = Modifier.height(4.dp)) + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: AllTimeStatsCardUiState.Loaded, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string + .stats_insights_all_time_stats_title + ), + style = + MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + StatsCardMenu( + onRemoveClick = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + Spacer(modifier = Modifier.height(8.dp)) + StatRow( + icon = Icons.Outlined.Visibility, + labelRes = R.string.stats_views, + value = formatStatValue(state.views) + ) + StatRow( + icon = Icons.Outlined.People, + labelRes = R.string.stats_visitors, + value = formatStatValue(state.visitors) + ) + StatRow( + icon = + Icons.AutoMirrored.Outlined.Article, + labelRes = R.string.stats_insights_posts, + value = formatStatValue(state.posts) + ) + StatRow( + icon = + Icons.AutoMirrored.Outlined.Chat, + labelRes = R.string.stats_comments, + value = formatStatValue(state.comments) + ) + } +} + +@Composable +private fun StatRow( + icon: ImageVector, + @StringRes labelRes: Int, + value: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun ErrorContent( + state: AllTimeStatsCardUiState.Error, + onRemoveCard: () -> Unit, + onRetry: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string + .stats_insights_all_time_stats_title + ), + style = + MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + StatsCardMenu( + onRemoveClick = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = + Alignment.CenterHorizontally + ) { + Text( + text = state.message, + style = + MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text( + text = stringResource(R.string.retry) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AllTimeStatsCardLoadingPreview() { + AppThemeM3 { + AllTimeStatsCard( + uiState = AllTimeStatsCardUiState.Loading, + onRemoveCard = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun AllTimeStatsCardLoadedPreview() { + AppThemeM3 { + AllTimeStatsCard( + uiState = AllTimeStatsCardUiState.Loaded( + views = 6782856L, + visitors = 154791L, + posts = 2L, + comments = 0L + ), + onRemoveCard = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun AllTimeStatsCardErrorPreview() { + AppThemeM3 { + AllTimeStatsCard( + uiState = AllTimeStatsCardUiState.Error( + message = "Failed to load stats" + ), + onRemoveCard = {}, + onRetry = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCardUiState.kt new file mode 100644 index 000000000000..df4351a50012 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCardUiState.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.ui.newstats.alltimestats + +sealed class AllTimeStatsCardUiState { + data object Loading : AllTimeStatsCardUiState() + + data class Loaded( + val views: Long, + val visitors: Long, + val posts: Long, + val comments: Long + ) : AllTimeStatsCardUiState() + + data class Error( + val message: String + ) : AllTimeStatsCardUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModel.kt new file mode 100644 index 000000000000..1c633eb26cca --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModel.kt @@ -0,0 +1,142 @@ +package org.wordpress.android.ui.newstats.alltimestats + +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.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase +import org.wordpress.android.util.AppLog +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +@HiltViewModel +class AllTimeStatsViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val resourceProvider: ResourceProvider, + private val statsSummaryUseCase: StatsSummaryUseCase +) : ViewModel() { + private val _uiState = + MutableStateFlow( + AllTimeStatsCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() + + @Volatile + private var isLoading = false + + @Volatile + private var isLoadedSuccessfully = false + + fun loadDataIfNeeded() { + if (isLoadedSuccessfully || isLoading) return + isLoading = true + loadData() + } + + fun refresh() { + val site = selectedSiteRepository + .getSelectedSite() ?: return + viewModelScope.launch { + try { + _isRefreshing.value = true + loadDataInternal( + site.siteId, + forceRefresh = true + ) + } finally { + _isRefreshing.value = false + } + } + } + + fun loadData() { + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading = false + _uiState.value = AllTimeStatsCardUiState.Error( + message = resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + return + } + + _uiState.value = AllTimeStatsCardUiState.Loading + + viewModelScope.launch { + try { + loadDataInternal(site.siteId) + } finally { + isLoading = false + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadDataInternal( + siteId: Long, + forceRefresh: Boolean = false + ) { + try { + val result = statsSummaryUseCase( + siteId, + forceRefresh + ) + when (result) { + is StatsSummaryResult.Success -> { + isLoadedSuccessfully = true + _uiState.value = + AllTimeStatsCardUiState.Loaded( + views = result.data.views, + visitors = + result.data.visitors, + posts = result.data.posts, + comments = + result.data.comments + ) + } + is StatsSummaryResult.Error -> { + isLoadedSuccessfully = false + _uiState.value = + AllTimeStatsCardUiState.Error( + message = resourceProvider + .getString( + R.string + .stats_error_api + ) + ) + } + } + } catch (e: Exception) { + isLoadedSuccessfully = false + AppLog.e( + AppLog.T.STATS, + "Error loading stats summary: " + + "${e.message}", + e + ) + _uiState.value = + AllTimeStatsCardUiState.Error( + message = resourceProvider.getString( + R.string.stats_error_unknown + ) + ) + } + } + + fun onRetry() { + loadData() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index 5837e506af0b..9a8a55adf66b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -217,6 +217,16 @@ interface StatsDataSource { suspend fun fetchStatsInsights( siteId: Long ): StatsInsightsDataResult + + /** + * Fetches stats summary for a specific site. + * + * @param siteId The WordPress.com site ID + * @return Result containing the summary data or an error + */ + suspend fun fetchStatsSummary( + siteId: Long + ): StatsSummaryDataResult } /** @@ -572,3 +582,27 @@ data class YearInsightsData( val totalComments: Long, val avgComments: Double ) + +/** + * Result wrapper for stats summary fetch operation. + */ +sealed class StatsSummaryDataResult { + data class Success( + val data: StatsSummaryData + ) : StatsSummaryDataResult() + data class Error( + val errorType: StatsErrorType + ) : StatsSummaryDataResult() +} + +/** + * All-time stats summary data from the API. + */ +data class StatsSummaryData( + val views: Long, + val visitors: Long, + val posts: Long, + val comments: Long, + val viewsBestDay: String, + val viewsBestDayTotal: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index b65aa265f4b4..b071982a4372 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -992,7 +992,9 @@ class StatsDataSourceImpl @Inject constructor( requestBuilder.statsInsights() .getStatsInsights( wpComSiteId = siteId.toULong(), - params = StatsInsightsParams() + params = StatsInsightsParams( + locale = wpComLanguage + ) ) } @@ -1045,6 +1047,57 @@ class StatsDataSourceImpl @Inject constructor( } } + override suspend fun fetchStatsSummary( + siteId: Long + ): StatsSummaryDataResult { + val params = uniffi.wp_api.StatsSummaryParams( + locale = wpComLanguage + ) + val result = getOrCreateClient() + .request { requestBuilder -> + requestBuilder.statsSummary() + .getStatsSummary( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + logResultType("fetchStatsSummary", result) + + return when (result) { + is WpRequestResult.Success -> { + val stats = result.response.data.stats + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchStatsSummary success" + ) + StatsSummaryDataResult.Success( + StatsSummaryData( + views = stats.views.toLong(), + visitors = + stats.visitors.toLong(), + posts = stats.posts.toLong(), + comments = + stats.comments.toLong(), + viewsBestDay = + stats.viewsBestDay + .orEmpty(), + viewsBestDayTotal = + stats.viewsBestDayTotal + .toLong() + ) + ) + } + else -> logErrorAndReturn( + "fetchStatsSummary", + result + ) { + StatsSummaryDataResult.Error(it) + } + } + } + companion object { private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_FORBIDDEN = 403 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCard.kt new file mode 100644 index 000000000000..a64ed7080ce0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCard.kt @@ -0,0 +1,434 @@ +package org.wordpress.android.ui.newstats.mostpopularday + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.newstats.components.CardPosition +import org.wordpress.android.ui.newstats.components.StatsCardMenu +import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.ui.newstats.util.rememberShimmerBrush + +private val CardCornerRadius = 10.dp +private val CardPadding = 16.dp +private val CardMargin = 16.dp + +@Composable +@Suppress("LongParameterList") +fun MostPopularDayCard( + uiState: MostPopularDayCardUiState, + onRemoveCard: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + val borderColor = + MaterialTheme.colorScheme.outlineVariant + + Box( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = CardMargin, + vertical = 8.dp + ) + .clip(RoundedCornerShape(CardCornerRadius)) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape( + CardCornerRadius + ) + ) + .background( + MaterialTheme.colorScheme.surface + ) + ) { + when (uiState) { + is MostPopularDayCardUiState.Loading -> + LoadingContent() + is MostPopularDayCardUiState.NoData -> + NoDataContent( + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is MostPopularDayCardUiState.Loaded -> + LoadedContent( + uiState, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is MostPopularDayCardUiState.Error -> + ErrorContent( + uiState, + onRemoveCard, + onRetry, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + val shimmerBrush = rememberShimmerBrush() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + Box( + modifier = Modifier + .width(180.dp) + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(20.dp)) + Box( + modifier = Modifier + .width(40.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .width(160.dp) + .height(32.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .width(40.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(20.dp)) + Box( + modifier = Modifier + .width(50.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .width(80.dp) + .height(32.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .width(100.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun NoDataContent( + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string + .stats_insights_most_popular_day + ), + style = MaterialTheme.typography + .titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + StatsCardMenu( + onRemoveClick = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource( + R.string.stats_no_data_yet + ), + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: MostPopularDayCardUiState.Loaded, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string + .stats_insights_most_popular_day + ), + style = MaterialTheme.typography + .titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + StatsCardMenu( + onRemoveClick = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + Spacer(modifier = Modifier.height(12.dp)) + // Day section + Text( + text = stringResource( + R.string + .stats_insights_most_popular_day_label + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.dayAndMonth, + style = MaterialTheme.typography + .headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + if (state.year.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = state.year, + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(16.dp)) + // Views section + Text( + text = stringResource(R.string.stats_views), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatStatValue(state.views), + style = MaterialTheme.typography + .headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource( + R.string + .stats_insights_most_popular_day_percent, + state.viewsPercentage + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun ErrorContent( + state: MostPopularDayCardUiState.Error, + onRemoveCard: () -> Unit, + onRetry: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string + .stats_insights_most_popular_day + ), + style = MaterialTheme.typography + .titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + StatsCardMenu( + onRemoveClick = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = + Alignment.CenterHorizontally + ) { + Text( + text = state.message, + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text( + text = stringResource( + R.string.retry + ) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MostPopularDayCardLoadingPreview() { + AppThemeM3 { + MostPopularDayCard( + uiState = + MostPopularDayCardUiState.Loading, + onRemoveCard = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MostPopularDayCardLoadedPreview() { + AppThemeM3 { + MostPopularDayCard( + uiState = + MostPopularDayCardUiState.Loaded( + dayAndMonth = "February 22", + year = "2022", + views = 4600L, + viewsPercentage = "0.068" + ), + onRemoveCard = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MostPopularDayCardErrorPreview() { + AppThemeM3 { + MostPopularDayCard( + uiState = MostPopularDayCardUiState.Error( + message = "Failed to load stats" + ), + onRemoveCard = {}, + onRetry = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCardUiState.kt new file mode 100644 index 000000000000..bb8e879ba897 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCardUiState.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.ui.newstats.mostpopularday + +sealed class MostPopularDayCardUiState { + data object Loading : MostPopularDayCardUiState() + + data object NoData : MostPopularDayCardUiState() + + data class Loaded( + val dayAndMonth: String, + val year: String, + val views: Long, + val viewsPercentage: String + ) : MostPopularDayCardUiState() + + data class Error( + val message: String + ) : MostPopularDayCardUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModel.kt new file mode 100644 index 000000000000..11c3012ce05c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModel.kt @@ -0,0 +1,206 @@ +package org.wordpress.android.ui.newstats.mostpopularday + +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.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsSummaryData +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase +import org.wordpress.android.util.AppLog +import org.wordpress.android.viewmodel.ResourceProvider +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class MostPopularDayViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val resourceProvider: ResourceProvider, + private val statsSummaryUseCase: StatsSummaryUseCase +) : ViewModel() { + private val _uiState = + MutableStateFlow( + MostPopularDayCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() + + @Volatile + private var isLoading = false + + @Volatile + private var isLoadedSuccessfully = false + + fun loadDataIfNeeded() { + if (isLoadedSuccessfully || isLoading) return + isLoading = true + loadData() + } + + fun refresh() { + val site = selectedSiteRepository + .getSelectedSite() ?: return + viewModelScope.launch { + try { + _isRefreshing.value = true + loadDataInternal( + site.siteId, + forceRefresh = true + ) + } finally { + _isRefreshing.value = false + } + } + } + + fun loadData() { + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading = false + _uiState.value = + MostPopularDayCardUiState.Error( + message = resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + return + } + + _uiState.value = MostPopularDayCardUiState.Loading + + viewModelScope.launch { + try { + loadDataInternal(site.siteId) + } finally { + isLoading = false + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadDataInternal( + siteId: Long, + forceRefresh: Boolean = false + ) { + try { + val result = statsSummaryUseCase( + siteId, + forceRefresh + ) + when (result) { + is StatsSummaryResult.Success -> { + isLoadedSuccessfully = true + _uiState.value = mapToUiState( + result.data + ) + } + is StatsSummaryResult.Error -> { + isLoadedSuccessfully = false + _uiState.value = + MostPopularDayCardUiState.Error( + message = resourceProvider + .getString( + R.string + .stats_error_api + ) + ) + } + } + } catch (e: Exception) { + isLoadedSuccessfully = false + AppLog.e( + AppLog.T.STATS, + "Error loading most popular day: " + + "${e.message}", + e + ) + _uiState.value = + MostPopularDayCardUiState.Error( + message = resourceProvider.getString( + R.string.stats_error_unknown + ) + ) + } + } + + fun onRetry() { + loadData() + } + + companion object { + private val INPUT_FORMAT = + DateTimeFormatter.ISO_LOCAL_DATE + private const val DISPLAY_PATTERN = "MMMM d" + private const val PERCENTAGE_MULTIPLIER = 100.0 + + internal fun mapToUiState( + data: StatsSummaryData + ): MostPopularDayCardUiState { + val bestDay = data.viewsBestDay + if (bestDay.isBlank()) { + return MostPopularDayCardUiState.NoData + } + val parsed = parseBestDay(bestDay) + val totalViews = data.views + val bestDayViews = data.viewsBestDayTotal + val percentage = if (totalViews > 0) { + val pct = bestDayViews.toDouble() / + totalViews.toDouble() * + PERCENTAGE_MULTIPLIER + String.format( + Locale.getDefault(), + "%.1f", + pct + ) + } else { + "0" + } + return MostPopularDayCardUiState.Loaded( + dayAndMonth = parsed.first, + year = parsed.second, + views = bestDayViews, + viewsPercentage = percentage + ) + } + + private fun parseBestDay( + bestDay: String + ): Pair { + return try { + val date = LocalDate.parse( + bestDay, + INPUT_FORMAT + ) + val displayFormat = + DateTimeFormatter.ofPattern( + DISPLAY_PATTERN, + Locale.getDefault() + ) + val dayMonth = date.format(displayFormat) + .replaceFirstChar { it.uppercase() } + val year = date.year.toString() + dayMonth to year + } catch ( + @Suppress( + "SwallowedException", + "TooGenericExceptionCaught" + ) + e: Exception + ) { + bestDay to "" + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt index 734a350f91f4..d3530babe389 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt @@ -19,9 +19,11 @@ import javax.inject.Named import javax.inject.Singleton @Singleton -class InsightsCardsConfigurationRepository @Inject constructor( +class InsightsCardsConfigurationRepository @Inject +constructor( private val appPrefsWrapper: AppPrefsWrapper, - @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher + @Named(IO_THREAD) + private val ioDispatcher: CoroutineDispatcher ) { private val mutex = Mutex() @@ -32,26 +34,34 @@ class InsightsCardsConfigurationRepository @Inject constructor( .create() private val _configurationFlow = - MutableStateFlow?>(null) + MutableStateFlow< + Pair? + >(null) val configurationFlow: - StateFlow?> = - _configurationFlow.asStateFlow() + StateFlow< + Pair? + > = _configurationFlow.asStateFlow() suspend fun getConfiguration( siteId: Long - ): InsightsCardsConfiguration = withContext(ioDispatcher) { - loadConfiguration(siteId) - } + ): InsightsCardsConfiguration = + withContext(ioDispatcher) { + mutex.withLock { + loadAndMigrate(siteId) + } + } - suspend fun saveConfiguration( + private fun persistConfiguration( siteId: Long, configuration: InsightsCardsConfiguration - ): Unit = withContext(ioDispatcher) { - appPrefsWrapper.setStatsInsightsCardsConfigurationJson( - siteId, - gson.toJson(configuration) - ) - _configurationFlow.value = siteId to configuration + ) { + appPrefsWrapper + .setStatsInsightsCardsConfigurationJson( + siteId, + gson.toJson(configuration) + ) + _configurationFlow.value = + siteId to configuration } suspend fun removeCard( @@ -59,13 +69,19 @@ class InsightsCardsConfigurationRepository @Inject constructor( cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { mutex.withLock { - val current = getConfiguration(siteId) + val current = loadAndMigrate(siteId) val newVisibleCards = current.visibleCards.toMutableList() newVisibleCards.remove(cardType) - saveConfiguration( + val newHiddenCards = + (current.hiddenCards + cardType) + .distinct() + persistConfiguration( siteId, - current.copy(visibleCards = newVisibleCards) + current.copy( + visibleCards = newVisibleCards, + hiddenCards = newHiddenCards + ) ) } } @@ -75,12 +91,22 @@ class InsightsCardsConfigurationRepository @Inject constructor( cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { mutex.withLock { - val current = getConfiguration(siteId) - if (current.visibleCards.contains(cardType)) return@withLock - val newVisibleCards = current.visibleCards + cardType - saveConfiguration( + val current = loadAndMigrate(siteId) + if (current.visibleCards + .contains(cardType) + ) { + return@withLock + } + val newVisibleCards = + current.visibleCards + cardType + val newHiddenCards = + current.hiddenCards - cardType + persistConfiguration( siteId, - current.copy(visibleCards = newVisibleCards) + current.copy( + visibleCards = newVisibleCards, + hiddenCards = newHiddenCards + ) ) } } @@ -90,11 +116,15 @@ class InsightsCardsConfigurationRepository @Inject constructor( cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { mutex.withLock { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) + val current = loadAndMigrate(siteId) + val index = + current.visibleCards.indexOf(cardType) if (index > 0) { moveCardToIndex( - siteId, current, cardType, index - 1 + siteId, + current, + cardType, + index - 1 ) } } @@ -105,10 +135,13 @@ class InsightsCardsConfigurationRepository @Inject constructor( cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { mutex.withLock { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) + val current = loadAndMigrate(siteId) + val index = + current.visibleCards.indexOf(cardType) if (index > 0) { - moveCardToIndex(siteId, current, cardType, 0) + moveCardToIndex( + siteId, current, cardType, 0 + ) } } } @@ -118,13 +151,17 @@ class InsightsCardsConfigurationRepository @Inject constructor( cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { mutex.withLock { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) + val current = loadAndMigrate(siteId) + val index = + current.visibleCards.indexOf(cardType) if (index >= 0 && index < current.visibleCards.size - 1 ) { moveCardToIndex( - siteId, current, cardType, index + 1 + siteId, + current, + cardType, + index + 1 ) } } @@ -135,8 +172,9 @@ class InsightsCardsConfigurationRepository @Inject constructor( cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { mutex.withLock { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) + val current = loadAndMigrate(siteId) + val index = + current.visibleCards.indexOf(cardType) if (index >= 0 && index < current.visibleCards.size - 1 ) { @@ -150,27 +188,47 @@ class InsightsCardsConfigurationRepository @Inject constructor( } } - private suspend fun moveCardToIndex( + private fun moveCardToIndex( siteId: Long, current: InsightsCardsConfiguration, cardType: InsightsCardType, newIndex: Int ) { - val newVisibleCards = current.visibleCards.toMutableList() + val newVisibleCards = + current.visibleCards.toMutableList() newVisibleCards.remove(cardType) newVisibleCards.add(newIndex, cardType) - saveConfiguration( + persistConfiguration( siteId, - current.copy(visibleCards = newVisibleCards) + current.copy( + visibleCards = newVisibleCards + ) ) } + /** + * Loads config from prefs and migrates if needed. + * Must be called within [mutex.withLock]. + */ + private fun loadAndMigrate( + siteId: Long + ): InsightsCardsConfiguration { + val config = loadConfiguration(siteId) + val migrated = addNewCardTypes(config) + if (migrated !== config) { + persistConfiguration(siteId, migrated) + } + return migrated + } + @Suppress("TooGenericExceptionCaught") private fun loadConfiguration( siteId: Long ): InsightsCardsConfiguration { val json = appPrefsWrapper - .getStatsInsightsCardsConfigurationJson(siteId) + .getStatsInsightsCardsConfigurationJson( + siteId + ) if (json == null) { return InsightsCardsConfiguration() } @@ -184,8 +242,9 @@ class InsightsCardsConfigurationRepository @Inject constructor( } else { AppLog.w( AppLog.T.STATS, - "Insights cards configuration contains " + - "invalid card types, resetting to default" + "Insights cards configuration " + + "contains invalid card types, " + + "resetting to default" ) resetToDefault(siteId) } @@ -193,13 +252,34 @@ class InsightsCardsConfigurationRepository @Inject constructor( AppLog.e( AppLog.T.STATS, "Failed to parse insights cards " + - "configuration, resetting to default", + "configuration, resetting to " + + "default", e ) resetToDefault(siteId) } } + /** + * Pure function: returns a migrated config with + * any new card types added, or the original config + * if no migration is needed. + */ + private fun addNewCardTypes( + config: InsightsCardsConfiguration + ): InsightsCardsConfiguration { + val allKnown = InsightsCardType.entries + val knownInConfig = config.visibleCards + + config.hiddenCards + val newTypes = + allKnown - knownInConfig.toSet() + if (newTypes.isEmpty()) return config + return config.copy( + visibleCards = + config.visibleCards + newTypes + ) + } + @Suppress("USELESS_CAST") private fun isValidConfiguration( config: InsightsCardsConfiguration @@ -211,11 +291,9 @@ class InsightsCardsConfigurationRepository @Inject constructor( private fun resetToDefault( siteId: Long ): InsightsCardsConfiguration { - val defaultConfig = InsightsCardsConfiguration() - appPrefsWrapper.setStatsInsightsCardsConfigurationJson( - siteId, - gson.toJson(defaultConfig) - ) + val defaultConfig = + InsightsCardsConfiguration() + persistConfiguration(siteId, defaultConfig) return defaultConfig } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index ab7639733ff6..07b729c67961 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -13,6 +13,8 @@ import org.wordpress.android.ui.newstats.datasource.RegionViewsDataResult import org.wordpress.android.ui.newstats.datasource.SearchTermsDataResult import org.wordpress.android.ui.newstats.datasource.StatsDataSource import org.wordpress.android.ui.newstats.datasource.StatsInsightsDataResult +import org.wordpress.android.ui.newstats.datasource.StatsSummaryDataResult +import org.wordpress.android.ui.newstats.datasource.StatsSummaryData import org.wordpress.android.ui.newstats.datasource.YearInsightsData import org.wordpress.android.ui.newstats.datasource.StatsDateRange import org.wordpress.android.ui.newstats.datasource.StatsUnit @@ -80,7 +82,6 @@ class StatsRepository @Inject constructor( @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { private val dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE - fun init(accessToken: String) { statsDataSource.init(accessToken) } @@ -1346,6 +1347,32 @@ class StatsRepository @Inject constructor( } } } + + suspend fun fetchStatsSummary( + siteId: Long + ): StatsSummaryResult = withContext(ioDispatcher) { + val result = + statsDataSource.fetchStatsSummary( + siteId = siteId + ) + when (result) { + is StatsSummaryDataResult.Success -> + StatsSummaryResult.Success( + data = result.data + ) + is StatsSummaryDataResult.Error -> { + appLogWrapper.e( + AppLog.T.STATS, + "Error fetching stats " + + "summary: " + + "${result.errorType}" + ) + StatsSummaryResult.Error( + result.errorType.name + ) + } + } + } } /** @@ -1740,3 +1767,15 @@ sealed class InsightsResult { val message: String ) : InsightsResult() } + +/** + * Result of fetching stats summary data from the repository. + */ +sealed class StatsSummaryResult { + data class Success( + val data: StatsSummaryData + ) : StatsSummaryResult() + data class Error( + val message: String + ) : StatsSummaryResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt new file mode 100644 index 000000000000..bbdff27903eb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt @@ -0,0 +1,47 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.newstats.datasource.StatsSummaryData +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatsSummaryUseCase @Inject constructor( + private val statsRepository: StatsRepository, + private val accountStore: AccountStore +) { + private val mutex = Mutex() + private var cachedSummary: + Pair? = null + + suspend operator fun invoke( + siteId: Long, + forceRefresh: Boolean = false + ): StatsSummaryResult { + val token = accountStore.accessToken + if (token.isNullOrEmpty()) { + return StatsSummaryResult.Error( + "No access token" + ) + } + statsRepository.init(token) + return mutex.withLock { + val cached = cachedSummary + if (!forceRefresh && + cached != null && + cached.first == siteId + ) { + return@withLock StatsSummaryResult + .Success(cached.second) + } + val result = + statsRepository.fetchStatsSummary(siteId) + if (result is StatsSummaryResult.Success) { + cachedSummary = siteId to result.data + } + result + } + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 13afbd278f09..13fb1e578791 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1509,6 +1509,10 @@ All-time + All-time stats + Most popular day + Day + %1$s%% of views Tags and Categories Check back when you\'ve published your first post! It\'s been %1$s since %2$s was published. Get the ball rolling and increase your post views by sharing your post: diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt index 1e9c403ed207..138505273237 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt @@ -18,9 +18,11 @@ class InsightsCardsConfigurationTest { visibleCards = emptyList() ) - val hiddenCards = config.hiddenCards() + val hiddenCards = config.computeHiddenCards() assertThat(hiddenCards).containsExactlyInAnyOrder( + InsightsCardType.ALL_TIME_STATS, + InsightsCardType.MOST_POPULAR_DAY, InsightsCardType.YEAR_IN_REVIEW ) } @@ -31,7 +33,7 @@ class InsightsCardsConfigurationTest { visibleCards = InsightsCardType.entries.toList() ) - val hiddenCards = config.hiddenCards() + val hiddenCards = config.computeHiddenCards() assertThat(hiddenCards).isEmpty() } @@ -42,7 +44,7 @@ class InsightsCardsConfigurationTest { visibleCards = emptyList() ) - val hiddenCards = config.hiddenCards() + val hiddenCards = config.computeHiddenCards() assertThat(hiddenCards).containsExactlyInAnyOrder( *InsightsCardType.entries.toTypedArray() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt index e3d0e572fa23..d9b309f5c181 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt @@ -9,6 +9,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -20,16 +21,19 @@ import org.wordpress.android.util.NetworkUtilsWrapper @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner.Silent::class) -class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { +class InsightsViewModelTest : + BaseUnitTest(StandardTestDispatcher()) { @Mock - private lateinit var selectedSiteRepository: SelectedSiteRepository + private lateinit var selectedSiteRepository: + SelectedSiteRepository @Mock private lateinit var cardConfigurationRepository: InsightsCardsConfigurationRepository @Mock - private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + private lateinit var networkUtilsWrapper: + NetworkUtilsWrapper private lateinit var viewModel: InsightsViewModel @@ -40,16 +44,21 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { } private val configurationFlow = - MutableStateFlow?>(null) + MutableStateFlow< + Pair? + >(null) @Before fun setUp() { - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(testSite) - whenever(cardConfigurationRepository.configurationFlow) - .thenReturn(configurationFlow) - whenever(networkUtilsWrapper.isNetworkAvailable()) - .thenReturn(true) + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + whenever( + cardConfigurationRepository.configurationFlow + ).thenReturn(configurationFlow) + whenever( + networkUtilsWrapper.isNetworkAvailable() + ).thenReturn(true) } private suspend fun initViewModel( @@ -57,7 +66,8 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { InsightsCardsConfiguration() ) { whenever( - cardConfigurationRepository.getConfiguration(TEST_SITE_ID) + cardConfigurationRepository + .getConfiguration(TEST_SITE_ID) ).thenReturn(config) viewModel = InsightsViewModel( selectedSiteRepository, @@ -73,23 +83,27 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { advanceUntilIdle() assertThat(viewModel.visibleCards.value) - .isEqualTo(InsightsCardType.defaultCards()) + .isEqualTo( + InsightsCardType.defaultCards() + ) } @Test fun `when initialized with custom config, then custom cards are visible`() = test { - val customConfig = InsightsCardsConfiguration( - visibleCards = listOf( - InsightsCardType.YEAR_IN_REVIEW + val customConfig = + InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW + ) ) - ) initViewModel(customConfig) advanceUntilIdle() - assertThat(viewModel.visibleCards.value).containsExactly( - InsightsCardType.YEAR_IN_REVIEW - ) + assertThat(viewModel.visibleCards.value) + .containsExactly( + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -98,12 +112,16 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { initViewModel() advanceUntilIdle() - viewModel.removeCard(InsightsCardType.YEAR_IN_REVIEW) + viewModel.removeCard( + InsightsCardType.YEAR_IN_REVIEW + ) advanceUntilIdle() - verify(cardConfigurationRepository).removeCard( - TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW - ) + verify(cardConfigurationRepository) + .removeCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -115,19 +133,25 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { initViewModel(config) advanceUntilIdle() - viewModel.addCard(InsightsCardType.YEAR_IN_REVIEW) + viewModel.addCard( + InsightsCardType.YEAR_IN_REVIEW + ) advanceUntilIdle() - verify(cardConfigurationRepository).addCard( - TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW - ) + verify(cardConfigurationRepository) + .addCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) } @Test fun `when configuration changes via flow, then state is updated`() = test { initViewModel( - InsightsCardsConfiguration(visibleCards = emptyList()) + InsightsCardsConfiguration( + visibleCards = emptyList() + ) ) advanceUntilIdle() @@ -136,12 +160,14 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { InsightsCardType.YEAR_IN_REVIEW ) ) - configurationFlow.value = TEST_SITE_ID to newConfig + configurationFlow.value = + TEST_SITE_ID to newConfig advanceUntilIdle() - assertThat(viewModel.visibleCards.value).containsExactly( - InsightsCardType.YEAR_IN_REVIEW - ) + assertThat(viewModel.visibleCards.value) + .containsExactly( + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -149,12 +175,14 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { test { initViewModel() advanceUntilIdle() - val initialCards = viewModel.visibleCards.value + val initialCards = + viewModel.visibleCards.value val newConfig = InsightsCardsConfiguration( visibleCards = emptyList() ) - configurationFlow.value = OTHER_SITE_ID to newConfig + configurationFlow.value = + OTHER_SITE_ID to newConfig advanceUntilIdle() assertThat(viewModel.visibleCards.value) @@ -172,7 +200,8 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { initViewModel(config) advanceUntilIdle() - val hiddenCards = viewModel.hiddenCards.value + val hiddenCards = + viewModel.hiddenCards.value assertThat(hiddenCards).doesNotContain( InsightsCardType.YEAR_IN_REVIEW @@ -180,37 +209,44 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `when no site selected, then config is not loaded`() = test { - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(null) + fun `when no site selected, then config is not loaded`() = + test { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) - viewModel = InsightsViewModel( - selectedSiteRepository, - cardConfigurationRepository, - networkUtilsWrapper - ) - advanceUntilIdle() + viewModel = InsightsViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper + ) + advanceUntilIdle() - verify(cardConfigurationRepository, never()) - .getConfiguration(org.mockito.kotlin.any()) - } + verify( + cardConfigurationRepository, + never() + ).getConfiguration(any()) + } @Test - fun `when no site selected, then removeCard is no-op`() = test { - initViewModel() - advanceUntilIdle() - - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(null) - viewModel.removeCard(InsightsCardType.YEAR_IN_REVIEW) - advanceUntilIdle() - - verify(cardConfigurationRepository, never()) - .removeCard( - org.mockito.kotlin.any(), - org.mockito.kotlin.any() + fun `when no site selected, then removeCard is no-op`() = + test { + initViewModel() + advanceUntilIdle() + + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + viewModel.removeCard( + InsightsCardType.YEAR_IN_REVIEW ) - } + advanceUntilIdle() + + verify( + cardConfigurationRepository, + never() + ).removeCard(any(), any()) + } @Test fun `when moveCardUp is called, then repository moveCardUp is invoked`() = @@ -218,12 +254,16 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { initViewModel() advanceUntilIdle() - viewModel.moveCardUp(InsightsCardType.YEAR_IN_REVIEW) + viewModel.moveCardUp( + InsightsCardType.YEAR_IN_REVIEW + ) advanceUntilIdle() - verify(cardConfigurationRepository).moveCardUp( - TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW - ) + verify(cardConfigurationRepository) + .moveCardUp( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -232,12 +272,16 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { initViewModel() advanceUntilIdle() - viewModel.moveCardToTop(InsightsCardType.YEAR_IN_REVIEW) + viewModel.moveCardToTop( + InsightsCardType.YEAR_IN_REVIEW + ) advanceUntilIdle() - verify(cardConfigurationRepository).moveCardToTop( - TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW - ) + verify(cardConfigurationRepository) + .moveCardToTop( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -246,12 +290,16 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { initViewModel() advanceUntilIdle() - viewModel.moveCardDown(InsightsCardType.YEAR_IN_REVIEW) + viewModel.moveCardDown( + InsightsCardType.YEAR_IN_REVIEW + ) advanceUntilIdle() - verify(cardConfigurationRepository).moveCardDown( - TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW - ) + verify(cardConfigurationRepository) + .moveCardDown( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -265,9 +313,11 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { ) advanceUntilIdle() - verify(cardConfigurationRepository).moveCardToBottom( - TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW - ) + verify(cardConfigurationRepository) + .moveCardToBottom( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -284,7 +334,8 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { networkUtilsWrapper ) - assertThat(viewModel.cardsToLoad.value).isEmpty() + assertThat(viewModel.cardsToLoad.value) + .isEmpty() } @Test @@ -299,48 +350,62 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { advanceUntilIdle() assertThat(viewModel.cardsToLoad.value) - .containsExactly(InsightsCardType.YEAR_IN_REVIEW) + .containsExactly( + InsightsCardType.YEAR_IN_REVIEW + ) } @Test fun `when initialized with network available, then isNetworkAvailable is true`() = test { - whenever(networkUtilsWrapper.isNetworkAvailable()) - .thenReturn(true) + whenever( + networkUtilsWrapper.isNetworkAvailable() + ).thenReturn(true) initViewModel() advanceUntilIdle() - assertThat(viewModel.isNetworkAvailable.value).isTrue() + assertThat( + viewModel.isNetworkAvailable.value + ).isTrue() } @Test fun `when initialized without network, then isNetworkAvailable is false`() = test { - whenever(networkUtilsWrapper.isNetworkAvailable()) - .thenReturn(false) + whenever( + networkUtilsWrapper.isNetworkAvailable() + ).thenReturn(false) initViewModel() advanceUntilIdle() - assertThat(viewModel.isNetworkAvailable.value).isFalse() + assertThat( + viewModel.isNetworkAvailable.value + ).isFalse() } @Test fun `when checkNetworkStatus is called, then network status is updated`() = test { - whenever(networkUtilsWrapper.isNetworkAvailable()) - .thenReturn(false) + whenever( + networkUtilsWrapper.isNetworkAvailable() + ).thenReturn(false) initViewModel() advanceUntilIdle() - assertThat(viewModel.isNetworkAvailable.value).isFalse() + assertThat( + viewModel.isNetworkAvailable.value + ).isFalse() - whenever(networkUtilsWrapper.isNetworkAvailable()) - .thenReturn(true) + whenever( + networkUtilsWrapper.isNetworkAvailable() + ).thenReturn(true) viewModel.checkNetworkStatus() - assertThat(viewModel.isNetworkAvailable.value).isTrue() + assertThat( + viewModel.isNetworkAvailable.value + ).isTrue() } companion object { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModelTest.kt new file mode 100644 index 000000000000..d3f4a54c86af --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModelTest.kt @@ -0,0 +1,407 @@ +package org.wordpress.android.ui.newstats.alltimestats + +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.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsSummaryData +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class AllTimeStatsViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + @Mock + private lateinit var statsSummaryUseCase: + StatsSummaryUseCase + + private lateinit var viewModel: AllTimeStatsViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + whenever( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn(NO_SITE_SELECTED_ERROR) + whenever( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(FAILED_TO_LOAD_ERROR) + whenever( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn(UNKNOWN_ERROR) + } + + private suspend fun initViewModel() { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success(createTestData()) + ) + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + } + + @Test + fun `when no site selected, then error state`() = + test { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + AllTimeStatsCardUiState.Error::class.java + ) + assertThat( + (state as AllTimeStatsCardUiState.Error) + .message + ).isEqualTo(NO_SITE_SELECTED_ERROR) + } + + @Test + fun `when data loads successfully, then loaded state`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + AllTimeStatsCardUiState.Loaded::class.java + ) + with( + state as AllTimeStatsCardUiState.Loaded + ) { + assertThat(views) + .isEqualTo(TEST_VIEWS) + assertThat(visitors) + .isEqualTo(TEST_VISITORS) + assertThat(posts) + .isEqualTo(TEST_POSTS) + assertThat(comments) + .isEqualTo(TEST_COMMENTS) + } + } + + @Test + fun `when fetch fails, then error state`() = test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Error("Network error") + ) + + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + AllTimeStatsCardUiState.Error::class.java + ) + assertThat( + (state as AllTimeStatsCardUiState.Error) + .message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Suppress("TooGenericExceptionThrown") + @Test + fun `when exception thrown, then error state`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenAnswer { + throw RuntimeException("Test") + } + + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + AllTimeStatsCardUiState.Error::class.java + ) + assertThat( + (state as AllTimeStatsCardUiState.Error) + .message + ).isEqualTo(UNKNOWN_ERROR) + } + + @Test + fun `when loadDataIfNeeded called multiple times, then loads once`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when onRetry called, then data is reloaded`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + + viewModel.onRetry() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when refresh called, then data is fetched`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + whenever( + statsSummaryUseCase( + TEST_SITE_ID, + forceRefresh = true + ) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when refresh called, then isRefreshing resets`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + whenever( + statsSummaryUseCase( + TEST_SITE_ID, + forceRefresh = true + ) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value) + .isFalse() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value) + .isFalse() + } + + @Test + fun `when refresh fails after success, then loadDataIfNeeded reloads`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + + viewModel = AllTimeStatsViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + + whenever( + statsSummaryUseCase( + TEST_SITE_ID, + forceRefresh = true + ) + ).thenReturn( + StatsSummaryResult.Error("Network error") + ) + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Error::class.java + ) + + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + createTestData() + ) + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_VIEWS = 6782856L + private const val TEST_VISITORS = 154791L + private const val TEST_POSTS = 42L + private const val TEST_COMMENTS = 85L + private const val NO_SITE_SELECTED_ERROR = + "No site selected" + private const val FAILED_TO_LOAD_ERROR = + "Failed to load stats" + private const val UNKNOWN_ERROR = + "Unknown error" + + private fun createTestData() = StatsSummaryData( + views = TEST_VIEWS, + visitors = TEST_VISITORS, + posts = TEST_POSTS, + comments = TEST_COMMENTS, + viewsBestDay = "2022-02-22", + viewsBestDayTotal = 4600L + ) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt new file mode 100644 index 000000000000..b97491fe0e3c --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt @@ -0,0 +1,327 @@ +package org.wordpress.android.ui.newstats.mostpopularday + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.lenient +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsSummaryData +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class MostPopularDayViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var resourceProvider: + ResourceProvider + + @Mock + private lateinit var statsSummaryUseCase: + StatsSummaryUseCase + + private lateinit var viewModel: + MostPopularDayViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + lenient().`when`( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn(NO_SITE_SELECTED_ERROR) + lenient().`when`( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(FAILED_TO_LOAD_ERROR) + lenient().`when`( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn(UNKNOWN_ERROR) + } + + private suspend fun initViewModel() { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success(createTestData()) + ) + viewModel = MostPopularDayViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + } + + @Test + fun `when data loads, then loaded state has correct day`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + data = createTestData() + ) + ) + + viewModel = MostPopularDayViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + MostPopularDayCardUiState + .Loaded::class.java + ) + with( + state as MostPopularDayCardUiState.Loaded + ) { + assertThat(dayAndMonth) + .isEqualTo("February 22") + assertThat(year).isEqualTo("2022") + assertThat(views) + .isEqualTo(TEST_BEST_DAY_TOTAL) + } + } + + @Test + fun `when no site selected, then error state`() = + test { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + MostPopularDayCardUiState + .Error::class.java + ) + } + + @Test + fun `when fetch fails, then error state`() = test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Error("Network error") + ) + + viewModel = MostPopularDayViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Error::class.java + ) + } + + @Test + fun `when loadDataIfNeeded called multiple times, then loads once`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + data = createTestData() + ) + ) + + viewModel = MostPopularDayViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when refresh called, then data is fetched`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenReturn( + StatsSummaryResult.Success( + data = createTestData() + ) + ) + whenever( + statsSummaryUseCase( + TEST_SITE_ID, + forceRefresh = true + ) + ).thenReturn( + StatsSummaryResult.Success( + data = createTestData() + ) + ) + + viewModel = MostPopularDayViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Loaded::class.java + ) + } + + @Suppress("TooGenericExceptionThrown") + @Test + fun `when exception thrown, then error state`() = + test { + whenever( + statsSummaryUseCase(TEST_SITE_ID) + ).thenAnswer { + throw RuntimeException("Test") + } + + viewModel = MostPopularDayViewModel( + selectedSiteRepository, + resourceProvider, + statsSummaryUseCase + ) + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Error::class.java + ) + assertThat( + (viewModel.uiState.value + as MostPopularDayCardUiState.Error) + .message + ).isEqualTo(UNKNOWN_ERROR) + } + + @Test + fun `when mapToUiState called, then percentage is calculated`() { + val data = StatsSummaryData( + views = 1000000L, + visitors = 0L, + posts = 0L, + comments = 0L, + viewsBestDay = "2022-02-22", + viewsBestDayTotal = 680L + ) + val state = + MostPopularDayViewModel.mapToUiState(data) + as MostPopularDayCardUiState.Loaded + assertThat(state.viewsPercentage) + .isEqualTo("0.1") + assertThat(state.dayAndMonth) + .isEqualTo("February 22") + assertThat(state.year).isEqualTo("2022") + } + + @Test + fun `when viewsBestDay is empty, then NoData state`() { + val data = StatsSummaryData( + views = 100L, + visitors = 0L, + posts = 0L, + comments = 0L, + viewsBestDay = "", + viewsBestDayTotal = 0L + ) + val state = + MostPopularDayViewModel.mapToUiState(data) + assertThat(state).isInstanceOf( + MostPopularDayCardUiState + .NoData::class.java + ) + } + + @Test + fun `when total views is zero, then percentage is zero`() { + val data = StatsSummaryData( + views = 0L, + visitors = 0L, + posts = 0L, + comments = 0L, + viewsBestDay = "2022-02-22", + viewsBestDayTotal = 0L + ) + val state = + MostPopularDayViewModel.mapToUiState(data) + as MostPopularDayCardUiState.Loaded + assertThat(state.viewsPercentage) + .isEqualTo("0") + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_VIEWS = 6782856L + private const val TEST_BEST_DAY = "2022-02-22" + private const val TEST_BEST_DAY_TOTAL = 4600L + private const val NO_SITE_SELECTED_ERROR = + "No site selected" + private const val FAILED_TO_LOAD_ERROR = + "Failed to load stats" + private const val UNKNOWN_ERROR = + "Unknown error" + + private fun createTestData() = StatsSummaryData( + views = TEST_VIEWS, + visitors = 154791L, + posts = 42L, + comments = 85L, + viewsBestDay = TEST_BEST_DAY, + viewsBestDayTotal = TEST_BEST_DAY_TOTAL + ) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt index 3da8c73e9c50..db3a11e1bb8c 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt @@ -63,11 +63,77 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { val config = repository.getConfiguration(TEST_SITE_ID) - assertThat(config.visibleCards).containsExactly( + assertThat(config.visibleCards).contains( InsightsCardType.YEAR_IN_REVIEW ) } + @Test + fun `when saved config missing new card types, then new types are added`() = + test { + val json = """ + { + "visibleCards": ["YEAR_IN_REVIEW"] + } + """.trimIndent() + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(json) + + val config = + repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards) + .containsExactly( + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.ALL_TIME_STATS, + InsightsCardType.MOST_POPULAR_DAY + ) + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), any() + ) + } + + @Test + fun `when saved config has all card types, then no update is saved`() = + test { + val json = """ + { + "visibleCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY" + ] + } + """.trimIndent() + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(json) + + val config = + repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards) + .containsExactly( + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.ALL_TIME_STATS, + InsightsCardType.MOST_POPULAR_DAY + ) + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) + } + @Test fun `when invalid json is saved, then default configuration is returned`() = test { @@ -87,17 +153,29 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { } @Test - fun `when saveConfiguration is called, then json is saved to prefs`() = + fun `when addCard is called on empty config, then json is saved to prefs`() = test { + val emptyJson = """ + { + "visibleCards": [], + "hiddenCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY" + ] + } + """.trimIndent() whenever( appPrefsWrapper - .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) - ).thenReturn(null) - val config = InsightsCardsConfiguration( - visibleCards = listOf(InsightsCardType.YEAR_IN_REVIEW) - ) + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(emptyJson) - repository.saveConfiguration(TEST_SITE_ID, config) + repository.addCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) verify(appPrefsWrapper) .setStatsInsightsCardsConfigurationJson( @@ -108,15 +186,12 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when removeCard is called, then card is removed from visible cards`() = test { - val initialJson = """ - { - "visibleCards": ["YEAR_IN_REVIEW"] - } - """.trimIndent() whenever( appPrefsWrapper - .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) - ).thenReturn(initialJson) + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) repository.removeCard( TEST_SITE_ID, @@ -128,8 +203,19 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { .setStatsInsightsCardsConfigurationJson( eq(TEST_SITE_ID), jsonCaptor.capture() ) - assertThat(jsonCaptor.firstValue) - .doesNotContain("YEAR_IN_REVIEW") + val savedConfig = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(savedConfig.visibleCards) + .doesNotContain( + InsightsCardType.YEAR_IN_REVIEW + ) + assertThat(savedConfig.hiddenCards) + .contains( + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -137,12 +223,19 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { test { val initialJson = """ { - "visibleCards": [] + "visibleCards": [], + "hiddenCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY" + ] } """.trimIndent() whenever( appPrefsWrapper - .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) ).thenReturn(initialJson) repository.addCard( @@ -153,30 +246,47 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { val jsonCaptor = argumentCaptor() verify(appPrefsWrapper) .setStatsInsightsCardsConfigurationJson( - eq(TEST_SITE_ID), jsonCaptor.capture() + eq(TEST_SITE_ID), + jsonCaptor.capture() ) assertThat(jsonCaptor.firstValue) .contains("YEAR_IN_REVIEW") } @Test - fun `when configurationFlow emits, then it contains site id and configuration`() = + fun `when mutation occurs, then configurationFlow emits site id and configuration`() = test { + val json = """ + { + "visibleCards": [], + "hiddenCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY" + ] + } + """.trimIndent() whenever( appPrefsWrapper - .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) - ).thenReturn(null) - val config = InsightsCardsConfiguration( - visibleCards = listOf(InsightsCardType.YEAR_IN_REVIEW) - ) + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(json) - repository.saveConfiguration(TEST_SITE_ID, config) + repository.addCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) - val flowValue = repository.configurationFlow.value + val flowValue = + repository.configurationFlow.value assertThat(flowValue).isNotNull - assertThat(flowValue?.first).isEqualTo(TEST_SITE_ID) + assertThat(flowValue?.first) + .isEqualTo(TEST_SITE_ID) assertThat(flowValue?.second?.visibleCards) - .containsExactly(InsightsCardType.YEAR_IN_REVIEW) + .contains( + InsightsCardType.YEAR_IN_REVIEW + ) } @Test @@ -205,134 +315,128 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when addCard is called with existing card, then card is not duplicated`() = test { - val initialJson = """ - { - "visibleCards": ["YEAR_IN_REVIEW"] - } - """.trimIndent() whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( TEST_SITE_ID ) - ).thenReturn(initialJson) + ).thenReturn(ALL_CARDS_JSON) repository.addCard( TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW ) - verify(appPrefsWrapper, org.mockito.kotlin.never()) - .setStatsInsightsCardsConfigurationJson( - any(), any() - ) + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) } @Test fun `when moveCardUp on first card, then order unchanged`() = test { - val initialJson = """ - { - "visibleCards": ["YEAR_IN_REVIEW"] - } - """.trimIndent() whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( TEST_SITE_ID ) - ).thenReturn(initialJson) + ).thenReturn(ALL_CARDS_JSON) repository.moveCardUp( TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW ) - verify(appPrefsWrapper, org.mockito.kotlin.never()) - .setStatsInsightsCardsConfigurationJson( - any(), any() - ) + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) } @Test fun `when moveCardDown on last card, then order unchanged`() = test { - val initialJson = """ - { - "visibleCards": ["YEAR_IN_REVIEW"] - } - """.trimIndent() whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( TEST_SITE_ID ) - ).thenReturn(initialJson) + ).thenReturn(ALL_CARDS_JSON) repository.moveCardDown( TEST_SITE_ID, - InsightsCardType.YEAR_IN_REVIEW + InsightsCardType.MOST_POPULAR_DAY ) - verify(appPrefsWrapper, org.mockito.kotlin.never()) - .setStatsInsightsCardsConfigurationJson( - any(), any() - ) + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) } @Test fun `when moveCardToTop on first card, then order unchanged`() = test { - val initialJson = """ - { - "visibleCards": ["YEAR_IN_REVIEW"] - } - """.trimIndent() whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( TEST_SITE_ID ) - ).thenReturn(initialJson) + ).thenReturn(ALL_CARDS_JSON) repository.moveCardToTop( TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW ) - verify(appPrefsWrapper, org.mockito.kotlin.never()) - .setStatsInsightsCardsConfigurationJson( - any(), any() - ) + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) } @Test fun `when moveCardToBottom on last card, then order unchanged`() = test { - val initialJson = """ - { - "visibleCards": ["YEAR_IN_REVIEW"] - } - """.trimIndent() whenever( appPrefsWrapper .getStatsInsightsCardsConfigurationJson( TEST_SITE_ID ) - ).thenReturn(initialJson) + ).thenReturn(ALL_CARDS_JSON) repository.moveCardToBottom( TEST_SITE_ID, - InsightsCardType.YEAR_IN_REVIEW + InsightsCardType.MOST_POPULAR_DAY ) - verify(appPrefsWrapper, org.mockito.kotlin.never()) - .setStatsInsightsCardsConfigurationJson( - any(), any() - ) + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) } companion object { private const val TEST_SITE_ID = 123L + private val ALL_CARDS_JSON = """ + { + "visibleCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY" + ] + } + """.trimIndent() } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt new file mode 100644 index 000000000000..b4181e5ffaa1 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt @@ -0,0 +1,153 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.newstats.datasource.StatsSummaryData + +@ExperimentalCoroutinesApi +class StatsSummaryUseCaseTest : BaseUnitTest() { + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var accountStore: AccountStore + + private lateinit var useCase: StatsSummaryUseCase + + @Before + fun setUp() { + whenever(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + useCase = StatsSummaryUseCase( + statsRepository, + accountStore + ) + } + + @Test + fun `when called, then returns cached on second call`() = + test { + whenever( + statsRepository.fetchStatsSummary( + TEST_SITE_ID + ) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummary() + ) + ) + + val first = useCase(TEST_SITE_ID) + val second = useCase(TEST_SITE_ID) + + assertThat(first).isInstanceOf( + StatsSummaryResult.Success::class.java + ) + assertThat(second).isInstanceOf( + StatsSummaryResult.Success::class.java + ) + verify(statsRepository, times(1)) + .fetchStatsSummary(TEST_SITE_ID) + } + + @Test + fun `when called with forceRefresh, then fetches again`() = + test { + whenever( + statsRepository.fetchStatsSummary( + TEST_SITE_ID + ) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummary() + ) + ) + + useCase(TEST_SITE_ID) + useCase( + TEST_SITE_ID, + forceRefresh = true + ) + + verify(statsRepository, times(2)) + .fetchStatsSummary(TEST_SITE_ID) + } + + @Test + fun `when called without token, then returns error`() = + test { + whenever(accountStore.accessToken) + .thenReturn(null) + useCase = StatsSummaryUseCase( + statsRepository, + accountStore + ) + + val result = useCase(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + StatsSummaryResult.Error::class.java + ) + verify(statsRepository, never()) + .fetchStatsSummary(any()) + } + + @Test + fun `when errors, then cache is not populated`() = + test { + whenever( + statsRepository.fetchStatsSummary( + TEST_SITE_ID + ) + ).thenReturn( + StatsSummaryResult.Error("Network error") + ) + + val first = useCase(TEST_SITE_ID) + assertThat(first).isInstanceOf( + StatsSummaryResult.Error::class.java + ) + + whenever( + statsRepository.fetchStatsSummary( + TEST_SITE_ID + ) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummary() + ) + ) + + val second = useCase(TEST_SITE_ID) + assertThat(second).isInstanceOf( + StatsSummaryResult.Success::class.java + ) + verify(statsRepository, times(2)) + .fetchStatsSummary(TEST_SITE_ID) + } + + private fun createTestSummary() = StatsSummaryData( + views = 100L, + visitors = 50L, + posts = 10L, + comments = 5L, + viewsBestDay = "2022-02-22", + viewsBestDayTotal = 20L + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af793c726cc7..cb3bb9cc1243 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '1219-9cb722b47cbf8380f7e7fd40d076773e1f87cea0' +wordpress-rs = '1219-1de57afce924622700bcc8d3a1f3ce893d8dad5b' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3'