From 2ff7aab2c9ae3b882b30c173d4c9538bb3b6140e Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 11:13:35 +0100 Subject: [PATCH 01/14] Update wordpress-rs to 1219-9cb722b47c Co-Authored-By: Claude Opus 4.6 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 42da04ed55e0..af793c726cc7 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 = 'trunk-4fac42b1a262e93adcbc0cd0353aafa0369671d2' +wordpress-rs = '1219-9cb722b47cbf8380f7e7fd40d076773e1f87cea0' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 6b5021f8cf2b8f4748c1c54c50d7be0bbbdfb59f Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 12:07:30 +0100 Subject: [PATCH 02/14] Add Insights tab with Year in Review card to new stats screen Populate the Insights tab with card management (add, remove, move) mirroring the Traffic tab architecture. Add Year in Review as the first Insights card, fetching yearly summaries (posts, words, likes, comments) via the wordpress-rs statsInsights endpoint. Co-Authored-By: Claude Opus 4.6 --- .../android/ui/newstats/InsightsCardType.kt | 16 + .../ui/newstats/InsightsCardsConfiguration.kt | 15 + .../android/ui/newstats/InsightsViewModel.kt | 137 ++++++ .../android/ui/newstats/NewStatsActivity.kt | 268 +++++++++++- .../ui/newstats/datasource/StatsDataSource.kt | 43 ++ .../datasource/StatsDataSourceImpl.kt | 62 +++ .../InsightsCardsConfigurationRepository.kt | 197 +++++++++ .../ui/newstats/repository/StatsRepository.kt | 38 ++ .../newstats/yearinreview/YearInReviewCard.kt | 407 ++++++++++++++++++ .../yearinreview/YearInReviewCardUiState.kt | 25 ++ .../yearinreview/YearInReviewViewModel.kt | 150 +++++++ .../wordpress/android/ui/prefs/AppPrefs.java | 21 + .../android/ui/prefs/AppPrefsWrapper.kt | 8 + WordPress/src/main/res/values/strings.xml | 1 + .../InsightsCardsConfigurationTest.kt | 73 ++++ .../ui/newstats/InsightsViewModelTest.kt | 334 ++++++++++++++ ...nsightsCardsConfigurationRepositoryTest.kt | 208 +++++++++ .../repository/StatsRepositoryInsightsTest.kt | 168 ++++++++ .../yearinreview/YearInReviewViewModelTest.kt | 397 +++++++++++++++++ 19 files changed, 2566 insertions(+), 2 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardsConfiguration.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt 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 new file mode 100644 index 000000000000..2b3791373734 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardType.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.ui.newstats + +import androidx.annotation.StringRes +import org.wordpress.android.R + +enum class InsightsCardType( + @StringRes val displayNameResId: Int +) { + YEAR_IN_REVIEW(R.string.stats_insights_year_in_review); + + companion object { + fun defaultCards(): List = listOf( + YEAR_IN_REVIEW + ) + } +} 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 new file mode 100644 index 000000000000..67b6d9a171a1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardsConfiguration.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.ui.newstats + +data class InsightsCardsConfiguration( + val visibleCards: List = + InsightsCardType.defaultCards() +) { + fun hiddenCards(): List { + return InsightsCardType.entries + .filter { it !in visibleCards } + } + + fun isCardVisible(cardType: InsightsCardType): Boolean { + return cardType 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 new file mode 100644 index 000000000000..0925a60964bf --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt @@ -0,0 +1,137 @@ +package org.wordpress.android.ui.newstats + +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.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.InsightsCardsConfigurationRepository +import org.wordpress.android.util.NetworkUtilsWrapper +import javax.inject.Inject + +@HiltViewModel +class InsightsViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val cardConfigurationRepository: + InsightsCardsConfigurationRepository, + private val networkUtilsWrapper: NetworkUtilsWrapper +) : ViewModel() { + private val _visibleCards = + MutableStateFlow>( + InsightsCardType.defaultCards() + ) + val visibleCards: StateFlow> = + _visibleCards.asStateFlow() + + private val _hiddenCards = + MutableStateFlow>(emptyList()) + val hiddenCards: StateFlow> = + _hiddenCards.asStateFlow() + + private val _isNetworkAvailable = MutableStateFlow(true) + val isNetworkAvailable: StateFlow = + _isNetworkAvailable.asStateFlow() + + private val _cardsToLoad = + MutableStateFlow>(emptyList()) + val cardsToLoad: StateFlow> = + _cardsToLoad.asStateFlow() + + private val siteId: Long + get() = selectedSiteRepository + .getSelectedSite()?.siteId ?: 0L + + init { + checkNetworkStatus() + loadConfiguration() + observeConfigurationChanges() + } + + fun checkNetworkStatus(): Boolean { + val isAvailable = networkUtilsWrapper.isNetworkAvailable() + _isNetworkAvailable.value = isAvailable + return isAvailable + } + + private fun loadConfiguration() { + val currentSiteId = siteId + viewModelScope.launch { + val config = cardConfigurationRepository + .getConfiguration(currentSiteId) + updateFromConfiguration(config) + } + } + + private fun observeConfigurationChanges() { + viewModelScope.launch { + cardConfigurationRepository.configurationFlow + .collect { pair -> + val currentSiteId = siteId + if (pair != null && + pair.first == currentSiteId + ) { + updateFromConfiguration(pair.second) + } + } + } + } + + private fun updateFromConfiguration( + config: InsightsCardsConfiguration + ) { + _visibleCards.value = config.visibleCards + _hiddenCards.value = config.hiddenCards() + _cardsToLoad.value = config.visibleCards + } + + fun removeCard(cardType: InsightsCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository + .removeCard(currentSiteId, cardType) + } + } + + fun addCard(cardType: InsightsCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository + .addCard(currentSiteId, cardType) + } + } + + fun moveCardUp(cardType: InsightsCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository + .moveCardUp(currentSiteId, cardType) + } + } + + fun moveCardToTop(cardType: InsightsCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository + .moveCardToTop(currentSiteId, cardType) + } + } + + fun moveCardDown(cardType: InsightsCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository + .moveCardDown(currentSiteId, cardType) + } + } + + fun moveCardToBottom(cardType: InsightsCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository + .moveCardToBottom(currentSiteId, cardType) + } + } +} 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 17ac5444652d..80876a5b02a6 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 @@ -43,6 +43,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -59,6 +61,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -96,6 +99,8 @@ 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.yearinreview.YearInReviewCard +import org.wordpress.android.ui.newstats.yearinreview.YearInReviewViewModel import org.wordpress.android.util.AppLog @AndroidEntryPoint @@ -242,9 +247,15 @@ private fun NewStatsScreen( } @Composable -private fun StatsTabContent(tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel) { +private fun StatsTabContent( + tab: StatsTab, + viewsStatsViewModel: ViewsStatsViewModel +) { when (tab) { - StatsTab.TRAFFIC -> TrafficTabContent(viewsStatsViewModel = viewsStatsViewModel) + StatsTab.TRAFFIC -> TrafficTabContent( + viewsStatsViewModel = viewsStatsViewModel + ) + StatsTab.INSIGHTS -> InsightsTabContent() else -> PlaceholderTabContent(tab) } } @@ -860,6 +871,259 @@ private fun List.dispatchToVisibleCards( if (StatsCardType.DEVICES in this) onDevices() } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongMethod") +private fun InsightsTabContent( + yearInReviewViewModel: YearInReviewViewModel = viewModel(), + insightsViewModel: InsightsViewModel = viewModel() +) { + val yearInReviewUiState by yearInReviewViewModel + .uiState.collectAsState() + val isYearInReviewRefreshing by yearInReviewViewModel + .isRefreshing.collectAsState() + val isRefreshing = isYearInReviewRefreshing + val pullToRefreshState = rememberPullToRefreshState() + + val visibleCards by insightsViewModel + .visibleCards.collectAsState() + val hiddenCards by insightsViewModel + .hiddenCards.collectAsState() + val isNetworkAvailable by insightsViewModel + .isNetworkAvailable.collectAsState() + val cardsToLoad by insightsViewModel + .cardsToLoad.collectAsState() + var showAddCardSheet by remember { mutableStateOf(false) } + val addCardSheetState = rememberModalBottomSheetState() + + LaunchedEffect(cardsToLoad) { + cardsToLoad.dispatchInsightsToVisibleCards( + onYearInReview = { + yearInReviewViewModel.loadDataIfNeeded() + } + ) + } + + if (showAddCardSheet) { + AddInsightsCardBottomSheet( + sheetState = addCardSheetState, + availableCards = hiddenCards, + onDismiss = { showAddCardSheet = false }, + onCardSelected = { cardType -> + insightsViewModel.addCard(cardType) + } + ) + } + + var showNoConnectionScreen by remember { + mutableStateOf(!isNetworkAvailable) + } + + val loadVisibleCards = { + visibleCards.dispatchInsightsToVisibleCards( + onYearInReview = { yearInReviewViewModel.loadData() } + ) + } + + LaunchedEffect(isNetworkAvailable) { + if (isNetworkAvailable && showNoConnectionScreen) { + showNoConnectionScreen = false + loadVisibleCards() + } else if (!isNetworkAvailable && + !showNoConnectionScreen + ) { + showNoConnectionScreen = true + } + } + + if (showNoConnectionScreen) { + NoConnectionContent( + onRetry = { + val isAvailable = + insightsViewModel.checkNetworkStatus() + if (isAvailable) { + showNoConnectionScreen = false + loadVisibleCards() + } + } + ) + return + } + + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + isRefreshing = isRefreshing, + state = pullToRefreshState, + onRefresh = { + insightsViewModel.checkNetworkStatus() + visibleCards.dispatchInsightsToVisibleCards( + onYearInReview = { + yearInReviewViewModel.refresh() + } + ) + }, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + if (visibleCards.isEmpty()) { + val emptyStateMessage = stringResource( + R.string.stats_no_cards_message + ) + Text( + text = emptyStateMessage, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + .semantics { + contentDescription = + emptyStateMessage + }, + textAlign = TextAlign.Center + ) + } + + val cardPositions = remember(visibleCards) { + visibleCards.mapIndexed { index, _ -> + CardPosition( + index = index, + totalCards = visibleCards.size + ) + } + } + + visibleCards.forEachIndexed { index, cardType -> + val cardPosition = cardPositions[index] + when (cardType) { + InsightsCardType.YEAR_IN_REVIEW -> + YearInReviewCard( + uiState = yearInReviewUiState, + onRemoveCard = { + insightsViewModel + .removeCard(cardType) + }, + cardPosition = cardPosition, + onMoveUp = { + insightsViewModel + .moveCardUp(cardType) + }, + onMoveToTop = { + insightsViewModel + .moveCardToTop(cardType) + }, + onMoveDown = { + insightsViewModel + .moveCardDown(cardType) + }, + onMoveToBottom = { + insightsViewModel + .moveCardToBottom(cardType) + } + ) + } + } + + AddCardButton( + onClick = { showAddCardSheet = true }, + modifier = Modifier.padding(16.dp) + ) + } + } +} + +private fun List.dispatchInsightsToVisibleCards( + onYearInReview: () -> Unit +) { + if (InsightsCardType.YEAR_IN_REVIEW in this) { + onYearInReview() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddInsightsCardBottomSheet( + sheetState: SheetState, + availableCards: List, + onDismiss: () -> Unit, + onCardSelected: (InsightsCardType) -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = stringResource(R.string.stats_add_card_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (availableCards.isEmpty()) { + Text( + text = stringResource( + R.string.stats_all_cards_visible + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp) + ) + } else { + availableCards.forEach { cardType -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onCardSelected(cardType) + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = + Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme + .colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource( + cardType.displayNameResId + ), + style = MaterialTheme + .typography.bodyLarge, + color = MaterialTheme + .colorScheme.onSurface + ) + } + } + } + } + } +} + @Composable private fun NoConnectionContent( onRetry: () -> Unit 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 80919743050d..fec6775f104b 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 @@ -207,6 +207,16 @@ interface StatsDataSource { dateRange: StatsDateRange, max: Int = 10 ): DevicesDataResult + + /** + * Fetches stats insights for a specific site. + * + * @param siteId The WordPress.com site ID as String + * @return Result containing the insights data or an error + */ + suspend fun fetchStatsInsights( + siteId: String + ): StatsInsightsDataResult } /** @@ -529,3 +539,36 @@ sealed class DevicesDataResult { * (percentage for screen size, view count for browser/platform). */ data class DevicesData(val items: Map) + +/** + * Result wrapper for stats insights fetch operation. + */ +sealed class StatsInsightsDataResult { + data class Success( + val data: StatsInsightsData + ) : StatsInsightsDataResult() + data class Error( + val errorType: StatsErrorType + ) : StatsInsightsDataResult() +} + +/** + * Stats insights data from the API. + */ +data class StatsInsightsData( + val years: List +) + +/** + * A single year's insights summary. + */ +data class YearInsightsData( + val year: String, + val totalPosts: Long, + val totalWords: Long, + val avgWords: Double, + val totalLikes: Long, + val avgLikes: Double, + val totalComments: Long, + val avgComments: Double +) 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 b99735eed048..51c395b03901 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 @@ -18,6 +18,7 @@ import uniffi.wp_api.StatsRegionViewsParams import uniffi.wp_api.StatsRegionViewsPeriod import uniffi.wp_api.StatsDevicesParams import uniffi.wp_api.StatsDevicesPeriod +import uniffi.wp_api.StatsInsightsParams import uniffi.wp_api.StatsSearchTermsParams import uniffi.wp_api.StatsSearchTermsPeriod import uniffi.wp_api.StatsTopAuthorsParams @@ -983,6 +984,67 @@ class StatsDataSourceImpl @Inject constructor( } } + override suspend fun fetchStatsInsights( + siteId: String + ): StatsInsightsDataResult { + val result = getOrCreateClient() + .request { requestBuilder -> + requestBuilder.statsInsights() + .getStatsInsights( + wpComSiteId = siteId.toULong(), + params = StatsInsightsParams() + ) + } + + logResultType("fetchStatsInsights", result) + + return when (result) { + is WpRequestResult.Success -> { + val data = result.response.data + val years = data.years + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchStatsInsights success " + + "- ${years.size} years" + ) + StatsInsightsDataResult.Success( + StatsInsightsData( + years = years.map { yearData -> + YearInsightsData( + year = yearData.year, + totalPosts = + yearData.totalPosts + .toLong(), + totalWords = + yearData.totalWords + .toLong(), + avgWords = + yearData.avgWords, + totalLikes = + yearData.totalLikes + .toLong(), + avgLikes = + yearData.avgLikes, + totalComments = + yearData.totalComments + .toLong(), + avgComments = + yearData.avgComments + ) + } + ) + ) + } + else -> logErrorAndReturn( + "fetchStatsInsights", + result + ) { + StatsInsightsDataResult.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/repository/InsightsCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt new file mode 100644 index 000000000000..9a4339b68c96 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt @@ -0,0 +1,197 @@ +package org.wordpress.android.ui.newstats.repository + +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.ui.newstats.InsightsCardType +import org.wordpress.android.ui.newstats.InsightsCardsConfiguration +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.EnumWithFallbackValueTypeAdapterFactory +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class InsightsCardsConfigurationRepository @Inject constructor( + private val appPrefsWrapper: AppPrefsWrapper, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher +) { + private val gson = GsonBuilder() + .registerTypeAdapterFactory( + EnumWithFallbackValueTypeAdapterFactory() + ) + .create() + + private val _configurationFlow = + MutableStateFlow?>(null) + val configurationFlow: + StateFlow?> = + _configurationFlow.asStateFlow() + + suspend fun getConfiguration( + siteId: Long + ): InsightsCardsConfiguration = withContext(ioDispatcher) { + loadConfiguration(siteId) + } + + suspend fun saveConfiguration( + siteId: Long, + configuration: InsightsCardsConfiguration + ): Unit = withContext(ioDispatcher) { + appPrefsWrapper.setStatsInsightsCardsConfigurationJson( + siteId, + gson.toJson(configuration) + ) + _configurationFlow.value = siteId to configuration + } + + suspend fun removeCard( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val newVisibleCards = current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } + + suspend fun addCard( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val newVisibleCards = current.visibleCards + cardType + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } + + suspend fun moveCardUp( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex(siteId, current, cardType, index - 1) + } + } + + suspend fun moveCardToTop( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex(siteId, current, cardType, 0) + } + } + + suspend fun moveCardDown( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index >= 0 && index < current.visibleCards.size - 1) { + moveCardToIndex( + siteId, current, cardType, index + 1 + ) + } + } + + suspend fun moveCardToBottom( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index >= 0 && index < current.visibleCards.size - 1) { + moveCardToIndex( + siteId, + current, + cardType, + current.visibleCards.size - 1 + ) + } + } + + private suspend fun moveCardToIndex( + siteId: Long, + current: InsightsCardsConfiguration, + cardType: InsightsCardType, + newIndex: Int + ) { + val newVisibleCards = current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + newVisibleCards.add(newIndex, cardType) + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } + + @Suppress("TooGenericExceptionCaught") + private fun loadConfiguration( + siteId: Long + ): InsightsCardsConfiguration { + val json = appPrefsWrapper + .getStatsInsightsCardsConfigurationJson(siteId) + if (json == null) { + return InsightsCardsConfiguration() + } + return try { + val config = gson.fromJson( + json, + InsightsCardsConfiguration::class.java + ) + if (isValidConfiguration(config)) { + config + } else { + AppLog.w( + AppLog.T.STATS, + "Insights cards configuration contains " + + "invalid card types, resetting to default" + ) + resetToDefault(siteId) + } + } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Failed to parse insights cards " + + "configuration, resetting to default", + e + ) + resetToDefault(siteId) + } + } + + private fun isValidConfiguration( + config: InsightsCardsConfiguration + ): Boolean { + val validCards = + config.visibleCards.filterIsInstance() + return validCards.size == config.visibleCards.size + } + + private fun resetToDefault( + siteId: Long + ): InsightsCardsConfiguration { + val defaultConfig = InsightsCardsConfiguration() + appPrefsWrapper.setStatsInsightsCardsConfigurationJson( + siteId, + gson.toJson(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 b479c8fb2f62..c7d321b58ae1 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 @@ -12,6 +12,8 @@ import org.wordpress.android.ui.newstats.datasource.ReferrersDataResult 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.YearInsightsData import org.wordpress.android.ui.newstats.datasource.StatsDateRange import org.wordpress.android.ui.newstats.datasource.StatsUnit import org.wordpress.android.ui.newstats.datasource.StatsVisitsData @@ -1320,6 +1322,30 @@ class StatsRepository @Inject constructor( } } } + + suspend fun fetchInsights( + siteId: Long + ): InsightsResult = withContext(ioDispatcher) { + val result = statsDataSource.fetchStatsInsights( + siteId = siteId.toString() + ) + when (result) { + is StatsInsightsDataResult.Success -> + InsightsResult.Success( + years = result.data.years + ) + is StatsInsightsDataResult.Error -> { + appLogWrapper.e( + AppLog.T.STATS, + "Error fetching insights: " + + "${result.errorType}" + ) + InsightsResult.Error( + result.errorType.name + ) + } + } + } } /** @@ -1702,3 +1728,15 @@ data class DeviceItemData( val name: String, val views: Double ) + +/** + * Result of fetching insights data from the repository. + */ +sealed class InsightsResult { + data class Success( + val years: List + ) : InsightsResult() + data class Error( + val message: String + ) : InsightsResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt new file mode 100644 index 000000000000..90a3324e1ab6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -0,0 +1,407 @@ +package org.wordpress.android.ui.newstats.yearinreview + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +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 + +private val CardCornerRadius = 10.dp +private val CardPadding = 16.dp +private val CardMargin = 16.dp + +@Composable +fun YearInReviewCard( + uiState: YearInReviewCardUiState, + onRemoveCard: () -> 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 YearInReviewCardUiState.Loading -> + LoadingContent() + is YearInReviewCardUiState.Loaded -> + LoadedContent( + uiState, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is YearInReviewCardUiState.Error -> + ErrorContent( + uiState, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f), + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.6f), + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) + ) + + val transition = + rememberInfiniteTransition(label = "shimmer") + val translateAnimation by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1200, + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_translate" + ) + + val shimmerBrush = Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnimation - 500f, 0f), + end = Offset(translateAnimation, 0f) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + Box( + modifier = Modifier + .width(120.dp) + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(16.dp)) + repeat(2) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(40.dp) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.weight(1f)) + repeat(4) { + Box( + modifier = Modifier + .width(50.dp) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + } + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: YearInReviewCardUiState.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_year_in_review + ), + 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)) + // Column headers + MetricHeaderRow() + state.years.forEachIndexed { index, year -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme + .outlineVariant.copy(alpha = 0.5f) + ) + } + YearRow(year) + } + } +} + +@Composable +private fun MetricHeaderRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "", + modifier = Modifier.weight(1f) + ) + MetricHeaderLabel( + stringResource(R.string.stats_insights_posts) + ) + MetricHeaderLabel( + stringResource(R.string.stats_insights_total_words) + ) + MetricHeaderLabel( + stringResource(R.string.stats_insights_total_likes) + ) + MetricHeaderLabel( + stringResource(R.string.stats_comments) + ) + } +} + +@Composable +private fun MetricHeaderLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(60.dp), + maxLines = 1 + ) +} + +@Composable +private fun YearRow(year: YearSummary) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = year.year, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + MetricValue(formatStatValue(year.totalPosts)) + MetricValue(formatStatValue(year.totalWords)) + MetricValue(formatStatValue(year.totalLikes)) + MetricValue(formatStatValue(year.totalComments)) + } +} + +@Composable +private fun MetricValue(value: String) { + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.width(60.dp), + maxLines = 1 + ) +} + +@Suppress("LongParameterList") +@Composable +private fun ErrorContent( + state: YearInReviewCardUiState.Error, + 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_year_in_review + ), + 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 = state.onRetry) { + Text( + text = stringResource(R.string.retry) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun YearInReviewCardLoadingPreview() { + AppThemeM3 { + YearInReviewCard( + uiState = YearInReviewCardUiState.Loading, + onRemoveCard = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun YearInReviewCardLoadedPreview() { + AppThemeM3 { + YearInReviewCard( + uiState = YearInReviewCardUiState.Loaded( + years = listOf( + YearSummary( + year = "2025", + totalPosts = 42, + totalWords = 15000, + avgWords = 357.1, + totalLikes = 230, + avgLikes = 5.5, + totalComments = 85, + avgComments = 2.0 + ), + YearSummary( + year = "2024", + totalPosts = 38, + totalWords = 12500, + avgWords = 328.9, + totalLikes = 180, + avgLikes = 4.7, + totalComments = 60, + avgComments = 1.6 + ) + ) + ), + onRemoveCard = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun YearInReviewCardErrorPreview() { + AppThemeM3 { + YearInReviewCard( + uiState = YearInReviewCardUiState.Error( + message = "Failed to load stats", + onRetry = {} + ), + onRemoveCard = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt new file mode 100644 index 000000000000..e630c5e850b7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.ui.newstats.yearinreview + +sealed class YearInReviewCardUiState { + data object Loading : YearInReviewCardUiState() + + data class Loaded( + val years: List + ) : YearInReviewCardUiState() + + data class Error( + val message: String, + val onRetry: () -> Unit + ) : YearInReviewCardUiState() +} + +data class YearSummary( + val year: String, + val totalPosts: Long, + val totalWords: Long, + val avgWords: Double, + val totalLikes: Long, + val avgLikes: Double, + val totalComments: Long, + val avgComments: Double +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt new file mode 100644 index 000000000000..bfa2d7959f07 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -0,0 +1,150 @@ +package org.wordpress.android.ui.newstats.yearinreview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.YearInsightsData +import org.wordpress.android.ui.newstats.repository.InsightsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +@HiltViewModel +class YearInReviewViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + YearInReviewCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() + + private var isLoading = false + 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) + } finally { + _isRefreshing.value = false + } + } + } + + fun loadData() { + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading = false + _uiState.value = YearInReviewCardUiState.Error( + message = resourceProvider.getString( + R.string.stats_error_no_site + ), + onRetry = ::loadData + ) + return + } + + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + isLoading = false + _uiState.value = YearInReviewCardUiState.Error( + message = resourceProvider.getString( + R.string.stats_error_api + ), + onRetry = ::loadData + ) + return + } + + statsRepository.init(accessToken) + _uiState.value = YearInReviewCardUiState.Loading + + viewModelScope.launch { + try { + loadDataInternal(site) + } finally { + isLoading = false + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadDataInternal(site: SiteModel) { + try { + val result = statsRepository + .fetchInsights(site.siteId) + when (result) { + is InsightsResult.Success -> { + isLoadedSuccessfully = true + _uiState.value = + YearInReviewCardUiState.Loaded( + years = result.years + .map { it.toUiModel() } + ) + } + is InsightsResult.Error -> { + _uiState.value = + YearInReviewCardUiState.Error( + message = resourceProvider + .getString( + R.string.stats_error_api + ), + onRetry = ::loadData + ) + } + } + } catch (e: Exception) { + _uiState.value = YearInReviewCardUiState.Error( + message = e.message + ?: resourceProvider.getString( + R.string.stats_error_unknown + ), + onRetry = ::loadData + ) + } + } + + fun onRetry() { + loadData() + } + + companion object { + private fun YearInsightsData.toUiModel() = + YearSummary( + year = year, + totalPosts = totalPosts, + totalWords = totalWords, + avgWords = avgWords, + totalLikes = totalLikes, + avgLikes = avgLikes, + totalComments = totalComments, + avgComments = avgComments + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 967107aec596..5bf3b2808f01 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -196,6 +196,7 @@ public enum DeletablePrefKey implements PrefKey { READER_READING_PREFERENCES_JSON, SHOULD_SHOW_READER_ANNOUNCEMENT_CARD, STATS_CARDS_CONFIGURATION_JSON, + STATS_INSIGHTS_CARDS_CONFIGURATION_JSON, } /** @@ -1792,6 +1793,26 @@ private static String getStatsCardsConfigurationKey(long siteId) { return DeletablePrefKey.STATS_CARDS_CONFIGURATION_JSON.name() + siteId; } + @Nullable + public static String getStatsInsightsCardsConfigurationJson(long siteId) { + return prefs().getString(getStatsInsightsCardsConfigurationKey(siteId), null); + } + + public static void setStatsInsightsCardsConfigurationJson(long siteId, @Nullable String json) { + SharedPreferences.Editor editor = prefs().edit(); + if (json == null) { + editor.remove(getStatsInsightsCardsConfigurationKey(siteId)); + } else { + editor.putString(getStatsInsightsCardsConfigurationKey(siteId), json); + } + editor.apply(); + } + + @NonNull + private static String getStatsInsightsCardsConfigurationKey(long siteId) { + return DeletablePrefKey.STATS_INSIGHTS_CARDS_CONFIGURATION_JSON.name() + siteId; + } + /** * Returns whether network request tracking (Chucker) is enabled. * This is a device-level preference that persists across logout/login cycles diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 2706800222e8..0d5238c284fe 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -105,6 +105,14 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setStatsCardsConfigurationJson(siteId: Long, json: String?) = AppPrefs.setStatsCardsConfigurationJson(siteId, json) + fun getStatsInsightsCardsConfigurationJson(siteId: Long): String? = + AppPrefs.getStatsInsightsCardsConfigurationJson(siteId) + + fun setStatsInsightsCardsConfigurationJson( + siteId: Long, + json: String? + ) = AppPrefs.setStatsInsightsCardsConfigurationJson(siteId, json) + fun getAppWidgetSiteId(appWidgetId: Int) = AppPrefs.getStatsWidgetSelectedSiteId(appWidgetId) fun setAppWidgetSiteId(siteId: Long, appWidgetId: Int) = AppPrefs.setStatsWidgetSelectedSiteId(siteId, appWidgetId) fun removeAppWidgetSiteId(appWidgetId: Int) = AppPrefs.removeStatsWidgetSelectedSiteId(appWidgetId) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 478c5723a1d6..35a3d320f936 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1498,6 +1498,7 @@ Best Hour Best Views Ever Today + Year in Review All-time 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 new file mode 100644 index 000000000000..1e9c403ed207 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt @@ -0,0 +1,73 @@ +package org.wordpress.android.ui.newstats + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class InsightsCardsConfigurationTest { + @Test + fun `when default configuration, then all default cards are visible`() { + val config = InsightsCardsConfiguration() + + assertThat(config.visibleCards) + .isEqualTo(InsightsCardType.defaultCards()) + } + + @Test + fun `when hiddenCards is called, then non-visible cards are returned`() { + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + + val hiddenCards = config.hiddenCards() + + assertThat(hiddenCards).containsExactlyInAnyOrder( + InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when all cards visible, then hiddenCards returns empty list`() { + val config = InsightsCardsConfiguration( + visibleCards = InsightsCardType.entries.toList() + ) + + val hiddenCards = config.hiddenCards() + + assertThat(hiddenCards).isEmpty() + } + + @Test + fun `when no cards visible, then hiddenCards returns all cards`() { + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + + val hiddenCards = config.hiddenCards() + + assertThat(hiddenCards).containsExactlyInAnyOrder( + *InsightsCardType.entries.toTypedArray() + ) + } + + @Test + fun `when isCardVisible is called for visible card, then returns true`() { + val config = InsightsCardsConfiguration( + visibleCards = listOf(InsightsCardType.YEAR_IN_REVIEW) + ) + + assertThat( + config.isCardVisible(InsightsCardType.YEAR_IN_REVIEW) + ).isTrue() + } + + @Test + fun `when isCardVisible is called for hidden card, then returns false`() { + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + + assertThat( + config.isCardVisible(InsightsCardType.YEAR_IN_REVIEW) + ).isFalse() + } +} 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 new file mode 100644 index 000000000000..c919780f8297 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt @@ -0,0 +1,334 @@ +package org.wordpress.android.ui.newstats + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.InsightsCardsConfigurationRepository +import org.wordpress.android.util.NetworkUtilsWrapper + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) +class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var cardConfigurationRepository: + InsightsCardsConfigurationRepository + + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + + private lateinit var viewModel: InsightsViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + private val configurationFlow = + MutableStateFlow?>(null) + + @Before + fun setUp() { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(testSite) + whenever(cardConfigurationRepository.configurationFlow) + .thenReturn(configurationFlow) + whenever(networkUtilsWrapper.isNetworkAvailable()) + .thenReturn(true) + } + + private suspend fun initViewModel( + config: InsightsCardsConfiguration = + InsightsCardsConfiguration() + ) { + whenever( + cardConfigurationRepository.getConfiguration(TEST_SITE_ID) + ).thenReturn(config) + viewModel = InsightsViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper + ) + } + + @Test + fun `when initialized with default config, then default cards are visible`() = + test { + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value) + .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 + ) + ) + initViewModel(customConfig) + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value).containsExactly( + InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when removeCard is called, then repository removeCard is invoked`() = + test { + initViewModel() + advanceUntilIdle() + + viewModel.removeCard(InsightsCardType.YEAR_IN_REVIEW) + advanceUntilIdle() + + verify(cardConfigurationRepository).removeCard( + TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when addCard is called, then repository addCard is invoked`() = + test { + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.addCard(InsightsCardType.YEAR_IN_REVIEW) + advanceUntilIdle() + + 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()) + ) + advanceUntilIdle() + + val newConfig = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW + ) + ) + configurationFlow.value = TEST_SITE_ID to newConfig + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value).containsExactly( + InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when configuration changes for different site, then state is not updated`() = + test { + initViewModel() + advanceUntilIdle() + val initialCards = viewModel.visibleCards.value + + val newConfig = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + configurationFlow.value = OTHER_SITE_ID to newConfig + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value) + .isEqualTo(initialCards) + } + + @Test + fun `when hiddenCards is calculated, then it excludes visible cards`() = + test { + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW + ) + ) + initViewModel(config) + advanceUntilIdle() + + val hiddenCards = viewModel.hiddenCards.value + + assertThat(hiddenCards).doesNotContain( + InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when no site selected, then siteId defaults to 0`() = test { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(null) + whenever( + cardConfigurationRepository.getConfiguration(0L) + ).thenReturn(InsightsCardsConfiguration()) + + viewModel = InsightsViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper + ) + advanceUntilIdle() + + verify(cardConfigurationRepository).getConfiguration(0L) + } + + @Test + fun `when moveCardUp is called, then repository moveCardUp is invoked`() = + test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardUp(InsightsCardType.YEAR_IN_REVIEW) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardUp( + TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when moveCardToTop is called, then repository moveCardToTop is invoked`() = + test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardToTop(InsightsCardType.YEAR_IN_REVIEW) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardToTop( + TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when moveCardDown is called, then repository moveCardDown is invoked`() = + test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardDown(InsightsCardType.YEAR_IN_REVIEW) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardDown( + TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when moveCardToBottom is called, then repository moveCardToBottom is invoked`() = + test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardToBottom( + InsightsCardType.YEAR_IN_REVIEW + ) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardToBottom( + TEST_SITE_ID, InsightsCardType.YEAR_IN_REVIEW + ) + } + + @Test + fun `when ViewModel is created, then cardsToLoad starts empty`() = + test { + whenever( + cardConfigurationRepository + .getConfiguration(TEST_SITE_ID) + ).thenReturn(InsightsCardsConfiguration()) + + viewModel = InsightsViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper + ) + + assertThat(viewModel.cardsToLoad.value).isEmpty() + } + + @Test + fun `when config loads, then cardsToLoad matches visible cards`() = + test { + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW + ) + ) + initViewModel(config) + advanceUntilIdle() + + assertThat(viewModel.cardsToLoad.value) + .containsExactly(InsightsCardType.YEAR_IN_REVIEW) + } + + @Test + fun `when initialized with network available, then isNetworkAvailable is true`() = + test { + whenever(networkUtilsWrapper.isNetworkAvailable()) + .thenReturn(true) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isNetworkAvailable.value).isTrue() + } + + @Test + fun `when initialized without network, then isNetworkAvailable is false`() = + test { + whenever(networkUtilsWrapper.isNetworkAvailable()) + .thenReturn(false) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isNetworkAvailable.value).isFalse() + } + + @Test + fun `when checkNetworkStatus is called, then network status is updated`() = + test { + whenever(networkUtilsWrapper.isNetworkAvailable()) + .thenReturn(false) + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isNetworkAvailable.value).isFalse() + + whenever(networkUtilsWrapper.isNetworkAvailable()) + .thenReturn(true) + viewModel.checkNetworkStatus() + + assertThat(viewModel.isNetworkAvailable.value).isTrue() + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val OTHER_SITE_ID = 456L + } +} 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 new file mode 100644 index 000000000000..dfd0a0081bb3 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt @@ -0,0 +1,208 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +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.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.ui.newstats.InsightsCardType +import org.wordpress.android.ui.newstats.InsightsCardsConfiguration +import org.wordpress.android.ui.prefs.AppPrefsWrapper + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) +class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { + @Mock + private lateinit var appPrefsWrapper: AppPrefsWrapper + + private lateinit var repository: InsightsCardsConfigurationRepository + + @Before + fun setUp() { + repository = InsightsCardsConfigurationRepository( + appPrefsWrapper, + UnconfinedTestDispatcher() + ) + } + + @Test + fun `when no saved configuration, then default configuration is returned`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) + ).thenReturn(null) + + val config = repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards) + .isEqualTo(InsightsCardType.defaultCards()) + } + + @Test + fun `when valid json is saved, then configuration is parsed correctly`() = + 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 + ) + } + + @Test + fun `when invalid json is saved, then default configuration is returned`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) + ).thenReturn("invalid json") + + val config = repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards) + .isEqualTo(InsightsCardType.defaultCards()) + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), any() + ) + } + + @Test + fun `when saveConfiguration is called, then json is saved to prefs`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) + ).thenReturn(null) + val config = InsightsCardsConfiguration( + visibleCards = listOf(InsightsCardType.YEAR_IN_REVIEW) + ) + + repository.saveConfiguration(TEST_SITE_ID, config) + + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), any() + ) + } + + @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) + + repository.removeCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + assertThat(jsonCaptor.firstValue) + .doesNotContain("YEAR_IN_REVIEW") + } + + @Test + fun `when addCard is called, then card is added to visible cards`() = + test { + val initialJson = """ + { + "visibleCards": [] + } + """.trimIndent() + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) + ).thenReturn(initialJson) + + repository.addCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + 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`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) + ).thenReturn(null) + val config = InsightsCardsConfiguration( + visibleCards = listOf(InsightsCardType.YEAR_IN_REVIEW) + ) + + repository.saveConfiguration(TEST_SITE_ID, config) + + val flowValue = repository.configurationFlow.value + assertThat(flowValue).isNotNull + assertThat(flowValue?.first).isEqualTo(TEST_SITE_ID) + assertThat(flowValue?.second?.visibleCards) + .containsExactly(InsightsCardType.YEAR_IN_REVIEW) + } + + @Test + fun `when config contains invalid card type, then default configuration is returned`() = + test { + val jsonWithInvalidCardType = """ + { + "visibleCards": ["INVALID_CARD"] + } + """.trimIndent() + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson(TEST_SITE_ID) + ).thenReturn(jsonWithInvalidCardType) + + val config = repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards) + .isEqualTo(InsightsCardType.defaultCards()) + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), any() + ) + } + + companion object { + private const val TEST_SITE_ID = 123L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt new file mode 100644 index 000000000000..36118718c772 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt @@ -0,0 +1,168 @@ +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.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsErrorType +import org.wordpress.android.ui.newstats.datasource.StatsInsightsData +import org.wordpress.android.ui.newstats.datasource.StatsInsightsDataResult +import org.wordpress.android.ui.newstats.datasource.YearInsightsData + +@ExperimentalCoroutinesApi +class StatsRepositoryInsightsTest : BaseUnitTest() { + @Mock + private lateinit var statsDataSource: StatsDataSource + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + repository = StatsRepository( + statsDataSource = statsDataSource, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + @Test + fun `given successful response, when fetchInsights, then success result is returned`() = + test { + whenever(statsDataSource.fetchStatsInsights(any())) + .thenReturn( + StatsInsightsDataResult.Success( + createTestInsightsData() + ) + ) + + val result = repository.fetchInsights(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + InsightsResult.Success::class.java + ) + val success = result as InsightsResult.Success + assertThat(success.years).hasSize(2) + assertThat(success.years[0].year).isEqualTo("2025") + assertThat(success.years[0].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) + assertThat(success.years[0].totalWords) + .isEqualTo(TEST_TOTAL_WORDS) + assertThat(success.years[0].totalLikes) + .isEqualTo(TEST_TOTAL_LIKES) + assertThat(success.years[0].totalComments) + .isEqualTo(TEST_TOTAL_COMMENTS) + } + + @Test + fun `given error response, when fetchInsights, then error result is returned`() = + test { + whenever(statsDataSource.fetchStatsInsights(any())) + .thenReturn( + StatsInsightsDataResult.Error( + StatsErrorType.NETWORK_ERROR + ) + ) + + val result = repository.fetchInsights(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + InsightsResult.Error::class.java + ) + } + + @Test + fun `given empty years list, when fetchInsights, then success with empty list`() = + test { + val emptyData = StatsInsightsData(years = emptyList()) + whenever(statsDataSource.fetchStatsInsights(any())) + .thenReturn( + StatsInsightsDataResult.Success(emptyData) + ) + + val result = repository.fetchInsights(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + InsightsResult.Success::class.java + ) + val success = result as InsightsResult.Success + assertThat(success.years).isEmpty() + } + + @Test + fun `when fetchInsights is called, then data source is called with correct site id`() = + test { + whenever(statsDataSource.fetchStatsInsights(any())) + .thenReturn( + StatsInsightsDataResult.Success( + createTestInsightsData() + ) + ) + + repository.fetchInsights(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsInsights( + TEST_SITE_ID.toString() + ) + } + + @Test + fun `given multiple years, when fetchInsights, then all years are mapped correctly`() = + test { + whenever(statsDataSource.fetchStatsInsights(any())) + .thenReturn( + StatsInsightsDataResult.Success( + createTestInsightsData() + ) + ) + + val result = repository.fetchInsights(TEST_SITE_ID) + + val success = result as InsightsResult.Success + assertThat(success.years[0].year).isEqualTo("2025") + assertThat(success.years[1].year).isEqualTo("2024") + assertThat(success.years[1].totalPosts).isEqualTo(38L) + } + + private fun createTestInsightsData() = StatsInsightsData( + years = listOf( + YearInsightsData( + year = "2025", + totalPosts = TEST_TOTAL_POSTS, + totalWords = TEST_TOTAL_WORDS, + avgWords = 357.1, + totalLikes = TEST_TOTAL_LIKES, + avgLikes = 5.5, + totalComments = TEST_TOTAL_COMMENTS, + avgComments = 2.0 + ), + YearInsightsData( + year = "2024", + totalPosts = 38L, + totalWords = 12500L, + avgWords = 328.9, + totalLikes = 180L, + avgLikes = 4.7, + totalComments = 60L, + avgComments = 1.6 + ) + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_TOTAL_POSTS = 42L + private const val TEST_TOTAL_WORDS = 15000L + private const val TEST_TOTAL_LIKES = 230L + private const val TEST_TOTAL_COMMENTS = 85L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt new file mode 100644 index 000000000000..5e7dc90589c1 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt @@ -0,0 +1,397 @@ +package org.wordpress.android.ui.newstats.yearinreview + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.YearInsightsData +import org.wordpress.android.ui.newstats.repository.InsightsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class YearInReviewViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: YearInReviewViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(testSite) + whenever(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + whenever( + resourceProvider.getString(R.string.stats_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 fun initViewModel() { + viewModel = YearInReviewViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadData() + } + + @Test + fun `when no site selected, then error state is emitted`() = + test { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + assertThat( + (state as YearInReviewCardUiState.Error).message + ).isEqualTo(NO_SITE_SELECTED_ERROR) + } + + @Test + fun `when data loads successfully, then loaded state is emitted`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Loaded::class.java + ) + with(state as YearInReviewCardUiState.Loaded) { + assertThat(years).hasSize(2) + assertThat(years[0].year).isEqualTo("2025") + assertThat(years[0].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) + assertThat(years[0].totalWords) + .isEqualTo(TEST_TOTAL_WORDS) + assertThat(years[0].totalLikes) + .isEqualTo(TEST_TOTAL_LIKES) + assertThat(years[0].totalComments) + .isEqualTo(TEST_TOTAL_COMMENTS) + } + } + + @Test + fun `when fetch fails with error, then error state is emitted`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn(InsightsResult.Error("Network error")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + assertThat( + (state as YearInReviewCardUiState.Error).message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when exception is thrown during fetch, then error state is emitted`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenThrow(RuntimeException("Test exception")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + assertThat( + (state as YearInReviewCardUiState.Error).message + ).isEqualTo("Test exception") + } + + @Test + fun `when exception with null message, then unknown error message`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenThrow(RuntimeException()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + assertThat( + (state as YearInReviewCardUiState.Error).message + ).isEqualTo(UNKNOWN_ERROR) + } + + @Test + fun `when onRetry is called, then data is reloaded`() = test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.onRetry() + advanceUntilIdle() + + verify(statsRepository, times(2)) + .fetchInsights(eq(TEST_SITE_ID)) + } + + @Test + fun `when refresh is called, then isRefreshing becomes true then false`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + } + + @Test + fun `when refresh is called, then data is fetched`() = test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + verify(statsRepository, times(2)) + .fetchInsights(eq(TEST_SITE_ID)) + } + + @Test + fun `when access token is null, then error state is emitted`() = + test { + whenever(accountStore.accessToken).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + assertThat( + (state as YearInReviewCardUiState.Error).message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when access token is empty, then error state is emitted`() = + test { + whenever(accountStore.accessToken).thenReturn("") + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + assertThat( + (state as YearInReviewCardUiState.Error).message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when loadData is called, then repository is initialized with access token`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + initViewModel() + advanceUntilIdle() + + verify(statsRepository).init(eq(TEST_ACCESS_TOKEN)) + } + + @Test + fun `when loadDataIfNeeded is called multiple times, then data is only loaded once`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + viewModel = YearInReviewViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsRepository, times(1)) + .fetchInsights(eq(TEST_SITE_ID)) + } + + @Test + fun `when error state retry is clicked, then data is reloaded`() = + test { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val errorState = + viewModel.uiState.value as YearInReviewCardUiState.Error + + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(testSite) + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + errorState.onRetry() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf( + YearInReviewCardUiState.Loaded::class.java + ) + } + + @Test + fun `when data loads with empty years, then loaded state shows empty list`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success(years = emptyList()) + ) + + initViewModel() + advanceUntilIdle() + + val state = + viewModel.uiState.value as YearInReviewCardUiState.Loaded + assertThat(state.years).isEmpty() + } + + private fun createTestYears() = listOf( + YearInsightsData( + year = "2025", + totalPosts = TEST_TOTAL_POSTS, + totalWords = TEST_TOTAL_WORDS, + avgWords = TEST_AVG_WORDS, + totalLikes = TEST_TOTAL_LIKES, + avgLikes = TEST_AVG_LIKES, + totalComments = TEST_TOTAL_COMMENTS, + avgComments = TEST_AVG_COMMENTS + ), + YearInsightsData( + year = "2024", + totalPosts = 38L, + totalWords = 12500L, + avgWords = 328.9, + totalLikes = 180L, + avgLikes = 4.7, + totalComments = 60L, + avgComments = 1.6 + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val TEST_TOTAL_POSTS = 42L + private const val TEST_TOTAL_WORDS = 15000L + private const val TEST_AVG_WORDS = 357.1 + private const val TEST_TOTAL_LIKES = 230L + private const val TEST_AVG_LIKES = 5.5 + private const val TEST_TOTAL_COMMENTS = 85L + private const val TEST_AVG_COMMENTS = 2.0 + 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" + } +} From bfe41570e7fa4e148ebbd696cf4150b78af21c20 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 12:13:55 +0100 Subject: [PATCH 03/14] Restyle Year in Review card with 2x2 grid of mini stat cards Update the card title to show "YEAR in review" instead of a generic title. Replace the table layout with a 2x2 grid of mini cards, each displaying an icon, label, and formatted value for Posts, Words, Likes, and Comments, matching the web design. Co-Authored-By: Claude Opus 4.6 --- .../newstats/yearinreview/YearInReviewCard.kt | 206 +++++++++--------- WordPress/src/main/res/values/strings.xml | 1 + 2 files changed, 109 insertions(+), 98 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt index 90a3324e1ab6..b49c7434d2fe 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.yearinreview +import androidx.annotation.StringRes import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -8,6 +9,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,10 +17,16 @@ 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.material3.Button -import androidx.compose.material3.HorizontalDivider +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.automirrored.outlined.TextSnippet +import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush +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 @@ -41,6 +50,7 @@ import org.wordpress.android.ui.newstats.util.formatStatValue private val CardCornerRadius = 10.dp private val CardPadding = 16.dp private val CardMargin = 16.dp +private val MiniCardCornerRadius = 8.dp @Composable fun YearInReviewCard( @@ -131,36 +141,34 @@ private fun LoadingContent() { .fillMaxWidth() .padding(CardPadding) ) { + // Title shimmer Box( modifier = Modifier - .width(120.dp) + .width(140.dp) .height(24.dp) .clip(RoundedCornerShape(4.dp)) .background(shimmerBrush) ) Spacer(modifier = Modifier.height(16.dp)) + // 2x2 grid shimmer repeat(2) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = + Arrangement.spacedBy(12.dp) ) { - Box( - modifier = Modifier - .width(40.dp) - .height(20.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerBrush) - ) - Spacer(modifier = Modifier.weight(1f)) - repeat(4) { + repeat(2) { Box( modifier = Modifier - .width(50.dp) - .height(20.dp) - .clip(RoundedCornerShape(4.dp)) + .weight(1f) + .height(80.dp) + .clip( + RoundedCornerShape( + MiniCardCornerRadius + ) + ) .background(shimmerBrush) ) - Spacer(modifier = Modifier.width(8.dp)) } } Spacer(modifier = Modifier.height(12.dp)) @@ -179,18 +187,23 @@ private fun LoadedContent( onMoveDown: (() -> Unit)?, onMoveToBottom: (() -> Unit)? ) { + val year = state.years.firstOrNull() ?: return + Column( modifier = Modifier .fillMaxWidth() .padding(CardPadding) ) { + // Title row: "2023 in review" + menu Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource( - R.string.stats_insights_year_in_review + R.string + .stats_insights_year_in_review_title, + year.year ), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, @@ -206,90 +219,97 @@ private fun LoadedContent( ) } Spacer(modifier = Modifier.height(12.dp)) - // Column headers - MetricHeaderRow() - state.years.forEachIndexed { index, year -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme - .outlineVariant.copy(alpha = 0.5f) - ) - } - YearRow(year) + // 2x2 grid of mini stat cards + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(12.dp) + ) { + StatMiniCard( + icon = Icons.AutoMirrored.Outlined.Article, + labelRes = R.string.stats_insights_posts, + value = formatStatValue(year.totalPosts), + modifier = Modifier.weight(1f) + ) + StatMiniCard( + icon = Icons.AutoMirrored.Outlined.TextSnippet, + labelRes = + R.string.stats_insights_total_words, + value = formatStatValue(year.totalWords), + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(12.dp) + ) { + StatMiniCard( + icon = Icons.Outlined.StarOutline, + labelRes = + R.string.stats_insights_total_likes, + value = formatStatValue(year.totalLikes), + modifier = Modifier.weight(1f) + ) + StatMiniCard( + icon = Icons.AutoMirrored.Outlined.Chat, + labelRes = R.string.stats_comments, + value = formatStatValue( + year.totalComments + ), + modifier = Modifier.weight(1f) + ) } } } @Composable -private fun MetricHeaderRow() { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically +private fun StatMiniCard( + icon: ImageVector, + @StringRes labelRes: Int, + value: String, + modifier: Modifier = Modifier +) { + val borderColor = MaterialTheme.colorScheme.outlineVariant + + Column( + modifier = modifier + .clip(RoundedCornerShape(MiniCardCornerRadius)) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape( + MiniCardCornerRadius + ) + ) + .background( + MaterialTheme.colorScheme.surfaceContainerLowest + ) + .padding(12.dp) ) { - Text( - text = "", - modifier = Modifier.weight(1f) - ) - MetricHeaderLabel( - stringResource(R.string.stats_insights_posts) - ) - MetricHeaderLabel( - stringResource(R.string.stats_insights_total_words) - ) - MetricHeaderLabel( - stringResource(R.string.stats_insights_total_likes) + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) - MetricHeaderLabel( - stringResource(R.string.stats_comments) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - } -} - -@Composable -private fun MetricHeaderLabel(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(60.dp), - maxLines = 1 - ) -} - -@Composable -private fun YearRow(year: YearSummary) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Spacer(modifier = Modifier.height(4.dp)) Text( - text = year.year, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface ) - MetricValue(formatStatValue(year.totalPosts)) - MetricValue(formatStatValue(year.totalWords)) - MetricValue(formatStatValue(year.totalLikes)) - MetricValue(formatStatValue(year.totalComments)) } } -@Composable -private fun MetricValue(value: String) { - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.width(60.dp), - maxLines = 1 - ) -} - @Suppress("LongParameterList") @Composable private fun ErrorContent( @@ -374,16 +394,6 @@ private fun YearInReviewCardLoadedPreview() { avgLikes = 5.5, totalComments = 85, avgComments = 2.0 - ), - YearSummary( - year = "2024", - totalPosts = 38, - totalWords = 12500, - avgWords = 328.9, - totalLikes = 180, - avgLikes = 4.7, - totalComments = 60, - avgComments = 1.6 ) ) ), diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 35a3d320f936..3103502919fc 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1499,6 +1499,7 @@ Best Views Ever Today Year in Review + %s in review All-time From a6388ec442df62da65e62f391a33a5b600cc94b9 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 12:30:18 +0100 Subject: [PATCH 04/14] Add Year in Review detail screen with View All functionality Add a detail screen showing all years with full stats (posts, comments, avg comments/post, likes, avg likes/post, words, avg words/post). The card shows the most recent year and a "View all" link when multiple years are available. Years are sorted newest first. Co-Authored-By: Claude Opus 4.6 --- WordPress/src/main/AndroidManifest.xml | 5 + .../android/ui/newstats/NewStatsActivity.kt | 9 + .../ui/newstats/util/StatsFormatter.kt | 10 + .../newstats/yearinreview/YearInReviewCard.kt | 18 +- .../yearinreview/YearInReviewCardUiState.kt | 6 +- .../YearInReviewDetailActivity.kt | 271 ++++++++++++++++++ .../yearinreview/YearInReviewViewModel.kt | 12 + .../yearinreview/YearInReviewViewModelTest.kt | 51 ++++ 8 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 2c2cff153fa3..ab9b3ce10c40 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -127,6 +127,11 @@ android:theme="@style/WordPress.NoActionBar" android:exported="false" /> + + Unit, + onShowAllClick: () -> Unit, modifier: Modifier = Modifier, cardPosition: CardPosition? = null, onMoveUp: (() -> Unit)? = null, @@ -84,6 +87,7 @@ fun YearInReviewCard( LoadedContent( uiState, onRemoveCard, + onShowAllClick, cardPosition, onMoveUp, onMoveToTop, @@ -181,6 +185,7 @@ private fun LoadingContent() { private fun LoadedContent( state: YearInReviewCardUiState.Loaded, onRemoveCard: () -> Unit, + onShowAllClick: () -> Unit, cardPosition: CardPosition?, onMoveUp: (() -> Unit)?, onMoveToTop: (() -> Unit)?, @@ -261,6 +266,10 @@ private fun LoadedContent( modifier = Modifier.weight(1f) ) } + if (state.years.size > 1) { + Spacer(modifier = Modifier.height(8.dp)) + ShowAllFooter(onClick = onShowAllClick) + } } } @@ -373,7 +382,8 @@ private fun YearInReviewCardLoadingPreview() { AppThemeM3 { YearInReviewCard( uiState = YearInReviewCardUiState.Loading, - onRemoveCard = {} + onRemoveCard = {}, + onShowAllClick = {} ) } } @@ -397,7 +407,8 @@ private fun YearInReviewCardLoadedPreview() { ) ) ), - onRemoveCard = {} + onRemoveCard = {}, + onShowAllClick = {} ) } } @@ -411,7 +422,8 @@ private fun YearInReviewCardErrorPreview() { message = "Failed to load stats", onRetry = {} ), - onRemoveCard = {} + onRemoveCard = {}, + onShowAllClick = {} ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt index e630c5e850b7..93cf6ed4e3ba 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt @@ -1,5 +1,8 @@ package org.wordpress.android.ui.newstats.yearinreview +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + sealed class YearInReviewCardUiState { data object Loading : YearInReviewCardUiState() @@ -13,6 +16,7 @@ sealed class YearInReviewCardUiState { ) : YearInReviewCardUiState() } +@Parcelize data class YearSummary( val year: String, val totalPosts: Long, @@ -22,4 +26,4 @@ data class YearSummary( val avgLikes: Double, val totalComments: Long, val avgComments: Double -) +) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt new file mode 100644 index 000000000000..9532bd981cef --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt @@ -0,0 +1,271 @@ +package org.wordpress.android.ui.newstats.yearinreview + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.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 dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.util.extensions.getParcelableArrayListCompat + +private const val EXTRA_YEARS = "extra_years" +private val CardCornerRadius = 10.dp + +@AndroidEntryPoint +class YearInReviewDetailActivity : BaseAppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val years = intent.extras + ?.getParcelableArrayListCompat( + EXTRA_YEARS + ) ?: arrayListOf() + + setContent { + AppThemeM3 { + YearInReviewDetailScreen( + years = years, + onBackPressed = + onBackPressedDispatcher::onBackPressed + ) + } + } + } + + companion object { + fun start( + context: Context, + years: List + ) { + val intent = Intent( + context, + YearInReviewDetailActivity::class.java + ).apply { + putExtra(EXTRA_YEARS, ArrayList(years)) + } + context.startActivity(intent) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun YearInReviewDetailScreen( + years: List, + onBackPressed: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource( + R.string + .stats_insights_year_in_review + ) + ) + }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + Icons.AutoMirrored.Filled + .ArrowBack, + contentDescription = + stringResource(R.string.back) + ) + } + } + ) + } + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp), + verticalArrangement = + Arrangement.spacedBy(16.dp) + ) { + item { + Spacer(modifier = Modifier.height(0.dp)) + } + items(years) { year -> + YearDetailSection(year) + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun YearDetailSection(year: YearSummary) { + val borderColor = + MaterialTheme.colorScheme.outlineVariant + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CardCornerRadius)) + .border( + width = 1.dp, + color = borderColor, + shape = RoundedCornerShape(CardCornerRadius) + ) + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp) + ) { + Text( + text = stringResource( + R.string + .stats_insights_year_in_review_title, + year.year + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp) + ) + StatRow( + labelRes = R.string.stats_insights_posts, + value = formatStatValue(year.totalPosts) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_total_comments, + value = formatStatValue(year.totalComments) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_average_comments, + value = formatStatValue(year.avgComments) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_total_likes, + value = formatStatValue(year.totalLikes) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_average_likes, + value = formatStatValue(year.avgLikes) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_total_words, + value = formatStatValue(year.totalWords) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_average_words, + value = formatStatValue(year.avgWords) + ) + } +} + +@Composable +private fun StatRow( + @StringRes labelRes: Int, + value: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun StatDivider() { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant + .copy(alpha = 0.5f) + ) +} + +@Preview(showBackground = true) +@Composable +private fun YearInReviewDetailScreenPreview() { + AppThemeM3 { + YearInReviewDetailScreen( + years = listOf( + YearSummary( + year = "2025", + totalPosts = 42, + totalWords = 15000, + avgWords = 357.1, + totalLikes = 230, + avgLikes = 5.5, + totalComments = 85, + avgComments = 2.0 + ), + YearSummary( + year = "2024", + totalPosts = 38, + totalWords = 12500, + avgWords = 328.9, + totalLikes = 180, + avgLikes = 4.7, + totalComments = 60, + avgComments = 1.6 + ) + ), + onBackPressed = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt index bfa2d7959f07..0754bfa04fa7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -106,6 +106,9 @@ class YearInReviewViewModel @Inject constructor( YearInReviewCardUiState.Loaded( years = result.years .map { it.toUiModel() } + .sortedByDescending { + it.year + } ) } is InsightsResult.Error -> { @@ -134,6 +137,15 @@ class YearInReviewViewModel @Inject constructor( loadData() } + fun getDetailData(): List { + val state = _uiState.value + return if (state is YearInReviewCardUiState.Loaded) { + state.years + } else { + emptyList() + } + } + companion object { private fun YearInsightsData.toUiModel() = YearSummary( diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt index 5e7dc90589c1..9876a0a7e4a9 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt @@ -355,6 +355,57 @@ class YearInReviewViewModelTest : BaseUnitTest() { assertThat(state.years).isEmpty() } + @Test + fun `when state is loaded, then getDetailData returns years`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + initViewModel() + advanceUntilIdle() + + val detailData = viewModel.getDetailData() + assertThat(detailData).hasSize(2) + assertThat(detailData[0].year).isEqualTo("2025") + assertThat(detailData[0].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) + assertThat(detailData[1].year).isEqualTo("2024") + } + + @Test + fun `when state is loading, then getDetailData returns empty list`() = + test { + viewModel = YearInReviewViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + + val detailData = viewModel.getDetailData() + assertThat(detailData).isEmpty() + } + + @Test + fun `when state is error, then getDetailData returns empty list`() = + test { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(null) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + val detailData = viewModel.getDetailData() + assertThat(detailData).isEmpty() + } + private fun createTestYears() = listOf( YearInsightsData( year = "2025", From 54194a854944017cde314001f61161ae03bd2442 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 12:34:58 +0100 Subject: [PATCH 05/14] Always show current year in Year in Review card and always display Show All Ensure the current year is always present in the data, adding it with zero values if not returned by the API. The Show All footer is now always visible regardless of the number of years available. Co-Authored-By: Claude Opus 4.6 --- .../newstats/yearinreview/YearInReviewCard.kt | 6 +- .../yearinreview/YearInReviewViewModel.kt | 32 ++++++-- .../yearinreview/YearInReviewViewModelTest.kt | 73 +++++++++++++++---- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt index 6b6344e9c2ad..295179723fa2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -266,10 +266,8 @@ private fun LoadedContent( modifier = Modifier.weight(1f) ) } - if (state.years.size > 1) { - Spacer(modifier = Modifier.height(8.dp)) - ShowAllFooter(onClick = onShowAllClick) - } + Spacer(modifier = Modifier.height(8.dp)) + ShowAllFooter(onClick = onShowAllClick) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt index 0754bfa04fa7..7208c7008444 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -15,6 +15,7 @@ import org.wordpress.android.ui.newstats.datasource.YearInsightsData import org.wordpress.android.ui.newstats.repository.InsightsResult import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.viewmodel.ResourceProvider +import java.time.Year import javax.inject.Inject @HiltViewModel @@ -102,13 +103,15 @@ class YearInReviewViewModel @Inject constructor( when (result) { is InsightsResult.Success -> { isLoadedSuccessfully = true + val years = result.years + .map { it.toUiModel() } + .ensureCurrentYear() + .sortedByDescending { + it.year + } _uiState.value = YearInReviewCardUiState.Loaded( - years = result.years - .map { it.toUiModel() } - .sortedByDescending { - it.year - } + years = years ) } is InsightsResult.Error -> { @@ -158,5 +161,24 @@ class YearInReviewViewModel @Inject constructor( totalComments = totalComments, avgComments = avgComments ) + + private fun List.ensureCurrentYear(): + List { + val currentYear = Year.now().toString() + return if (any { it.year == currentYear }) { + this + } else { + this + YearSummary( + year = currentYear, + totalPosts = 0L, + totalWords = 0L, + avgWords = 0.0, + totalLikes = 0L, + avgLikes = 0.0, + totalComments = 0L, + avgComments = 0.0 + ) + } + } } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt index 9876a0a7e4a9..fcc73b7cb2c8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt @@ -19,6 +19,7 @@ import org.wordpress.android.ui.newstats.datasource.YearInsightsData import org.wordpress.android.ui.newstats.repository.InsightsResult import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.viewmodel.ResourceProvider +import java.time.Year @ExperimentalCoroutinesApi class YearInReviewViewModelTest : BaseUnitTest() { @@ -105,15 +106,17 @@ class YearInReviewViewModelTest : BaseUnitTest() { YearInReviewCardUiState.Loaded::class.java ) with(state as YearInReviewCardUiState.Loaded) { - assertThat(years).hasSize(2) - assertThat(years[0].year).isEqualTo("2025") - assertThat(years[0].totalPosts) + assertThat(years).hasSize(3) + assertThat(years[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(years[1].year).isEqualTo("2025") + assertThat(years[1].totalPosts) .isEqualTo(TEST_TOTAL_POSTS) - assertThat(years[0].totalWords) + assertThat(years[1].totalWords) .isEqualTo(TEST_TOTAL_WORDS) - assertThat(years[0].totalLikes) + assertThat(years[1].totalLikes) .isEqualTo(TEST_TOTAL_LIKES) - assertThat(years[0].totalComments) + assertThat(years[1].totalComments) .isEqualTo(TEST_TOTAL_COMMENTS) } } @@ -340,7 +343,7 @@ class YearInReviewViewModelTest : BaseUnitTest() { } @Test - fun `when data loads with empty years, then loaded state shows empty list`() = + fun `when data loads with empty years, then current year is added`() = test { whenever(statsRepository.fetchInsights(any())) .thenReturn( @@ -351,8 +354,48 @@ class YearInReviewViewModelTest : BaseUnitTest() { advanceUntilIdle() val state = - viewModel.uiState.value as YearInReviewCardUiState.Loaded - assertThat(state.years).isEmpty() + viewModel.uiState.value + as YearInReviewCardUiState.Loaded + assertThat(state.years).hasSize(1) + assertThat(state.years[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(state.years[0].totalPosts) + .isEqualTo(0L) + } + + @Test + fun `when current year exists in data, then no duplicate is added`() = + test { + val yearsWithCurrent = listOf( + YearInsightsData( + year = CURRENT_YEAR, + totalPosts = TEST_TOTAL_POSTS, + totalWords = TEST_TOTAL_WORDS, + avgWords = TEST_AVG_WORDS, + totalLikes = TEST_TOTAL_LIKES, + avgLikes = TEST_AVG_LIKES, + totalComments = TEST_TOTAL_COMMENTS, + avgComments = TEST_AVG_COMMENTS + ) + ) + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = yearsWithCurrent + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = + viewModel.uiState.value + as YearInReviewCardUiState.Loaded + assertThat(state.years).hasSize(1) + assertThat(state.years[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(state.years[0].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) } @Test @@ -369,11 +412,13 @@ class YearInReviewViewModelTest : BaseUnitTest() { advanceUntilIdle() val detailData = viewModel.getDetailData() - assertThat(detailData).hasSize(2) - assertThat(detailData[0].year).isEqualTo("2025") - assertThat(detailData[0].totalPosts) + assertThat(detailData).hasSize(3) + assertThat(detailData[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(detailData[1].year).isEqualTo("2025") + assertThat(detailData[1].totalPosts) .isEqualTo(TEST_TOTAL_POSTS) - assertThat(detailData[1].year).isEqualTo("2024") + assertThat(detailData[2].year).isEqualTo("2024") } @Test @@ -444,5 +489,7 @@ class YearInReviewViewModelTest : BaseUnitTest() { private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" private const val UNKNOWN_ERROR = "Unknown error" + private val CURRENT_YEAR = + Year.now().toString() } } From ba6cb89d45703747da010ff2cde7f241fa1b059e Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 12:43:48 +0100 Subject: [PATCH 06/14] Minor cleanup: cache current year and remove redundant variable Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/android/ui/newstats/NewStatsActivity.kt | 3 +-- .../ui/newstats/yearinreview/YearInReviewViewModel.kt | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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 1b3993ea0209..17f69fb5fed4 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 @@ -882,9 +882,8 @@ private fun InsightsTabContent( val context = LocalContext.current val yearInReviewUiState by yearInReviewViewModel .uiState.collectAsState() - val isYearInReviewRefreshing by yearInReviewViewModel + val isRefreshing by yearInReviewViewModel .isRefreshing.collectAsState() - val isRefreshing = isYearInReviewRefreshing val pullToRefreshState = rememberPullToRefreshState() val visibleCards by insightsViewModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt index 7208c7008444..b5ba66819b75 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -150,6 +150,8 @@ class YearInReviewViewModel @Inject constructor( } companion object { + private val CURRENT_YEAR = Year.now().toString() + private fun YearInsightsData.toUiModel() = YearSummary( year = year, @@ -164,12 +166,11 @@ class YearInReviewViewModel @Inject constructor( private fun List.ensureCurrentYear(): List { - val currentYear = Year.now().toString() - return if (any { it.year == currentYear }) { + return if (any { it.year == CURRENT_YEAR }) { this } else { this + YearSummary( - year = currentYear, + year = CURRENT_YEAR, totalPosts = 0L, totalWords = 0L, avgWords = 0.0, From 73e6607db3338ecd44dad94abcd702f150dab9d8 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 13:55:12 +0100 Subject: [PATCH 07/14] Fix code review issues and add missing tests - Use resource string for exception errors instead of raw e.message - Add duplicate guard in addCard() - Change Error from data class to class to fix lambda equality - Use Year.now() per-call instead of cached static val - Fix isValidConfiguration to check for null entries - Remove 0dp Spacer from detail screen - Add StatsFormatterTest with 12 tests - Add repository moveCard and addCard duplicate tests Co-Authored-By: Claude Opus 4.6 --- .../InsightsCardsConfigurationRepository.kt | 7 +- .../yearinreview/YearInReviewCardUiState.kt | 2 +- .../YearInReviewDetailActivity.kt | 3 - .../yearinreview/YearInReviewViewModel.kt | 20 +-- ...nsightsCardsConfigurationRepositoryTest.kt | 130 ++++++++++++++++++ .../ui/newstats/util/StatsFormatterTest.kt | 66 +++++++++ .../yearinreview/YearInReviewViewModelTest.kt | 2 +- 7 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/util/StatsFormatterTest.kt 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 9a4339b68c96..baeb607864c0 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 @@ -68,6 +68,7 @@ class InsightsCardsConfigurationRepository @Inject constructor( cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { val current = getConfiguration(siteId) + if (current.visibleCards.contains(cardType)) return@withContext val newVisibleCards = current.visibleCards + cardType saveConfiguration( siteId, @@ -176,12 +177,12 @@ class InsightsCardsConfigurationRepository @Inject constructor( } } + @Suppress("USELESS_CAST") private fun isValidConfiguration( config: InsightsCardsConfiguration ): Boolean { - val validCards = - config.visibleCards.filterIsInstance() - return validCards.size == config.visibleCards.size + return (config.visibleCards as List) + .none { it == null } } private fun resetToDefault( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt index 93cf6ed4e3ba..b89d4458a618 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt @@ -10,7 +10,7 @@ sealed class YearInReviewCardUiState { val years: List ) : YearInReviewCardUiState() - data class Error( + class Error( val message: String, val onRetry: () -> Unit ) : YearInReviewCardUiState() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt index 9532bd981cef..d9698081f285 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt @@ -120,9 +120,6 @@ private fun YearInReviewDetailScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - item { - Spacer(modifier = Modifier.height(0.dp)) - } items(years) { year -> YearDetailSection(year) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt index b5ba66819b75..8287d3a0f775 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -14,6 +14,7 @@ import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.datasource.YearInsightsData import org.wordpress.android.ui.newstats.repository.InsightsResult import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider import java.time.Year import javax.inject.Inject @@ -126,11 +127,15 @@ class YearInReviewViewModel @Inject constructor( } } } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Error loading insights: ${e.message}", + e + ) _uiState.value = YearInReviewCardUiState.Error( - message = e.message - ?: resourceProvider.getString( - R.string.stats_error_unknown - ), + message = resourceProvider.getString( + R.string.stats_error_unknown + ), onRetry = ::loadData ) } @@ -150,8 +155,6 @@ class YearInReviewViewModel @Inject constructor( } companion object { - private val CURRENT_YEAR = Year.now().toString() - private fun YearInsightsData.toUiModel() = YearSummary( year = year, @@ -166,11 +169,12 @@ class YearInReviewViewModel @Inject constructor( private fun List.ensureCurrentYear(): List { - return if (any { it.year == CURRENT_YEAR }) { + val currentYear = Year.now().toString() + return if (any { it.year == currentYear }) { this } else { this + YearSummary( - year = CURRENT_YEAR, + year = currentYear, totalPosts = 0L, totalWords = 0L, avgWords = 0.0, 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 dfd0a0081bb3..3da8c73e9c50 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 @@ -202,6 +202,136 @@ 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) + + repository.addCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + 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) + + repository.moveCardUp( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + 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) + + repository.moveCardDown( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + 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) + + repository.moveCardToTop( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + 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) + + repository.moveCardToBottom( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + verify(appPrefsWrapper, org.mockito.kotlin.never()) + .setStatsInsightsCardsConfigurationJson( + any(), any() + ) + } + companion object { private const val TEST_SITE_ID = 123L } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/util/StatsFormatterTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/util/StatsFormatterTest.kt new file mode 100644 index 000000000000..bc0ebd852c32 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/util/StatsFormatterTest.kt @@ -0,0 +1,66 @@ +package org.wordpress.android.ui.newstats.util + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class StatsFormatterTest { + @Test + fun `formatStatValue Long below 1000 returns raw number`() { + assertThat(formatStatValue(500L)).isEqualTo("500") + } + + @Test + fun `formatStatValue Long zero returns zero`() { + assertThat(formatStatValue(0L)).isEqualTo("0") + } + + @Test + fun `formatStatValue Long thousands returns K suffix`() { + assertThat(formatStatValue(1500L)).isEqualTo("1.5K") + } + + @Test + fun `formatStatValue Long exact thousand returns K suffix`() { + assertThat(formatStatValue(1000L)).isEqualTo("1.0K") + } + + @Test + fun `formatStatValue Long millions returns M suffix`() { + assertThat(formatStatValue(2500000L)).isEqualTo("2.5M") + } + + @Test + fun `formatStatValue Long exact million returns M suffix`() { + assertThat(formatStatValue(1000000L)).isEqualTo("1.0M") + } + + @Test + fun `formatStatValue Double whole number returns no decimals`() { + assertThat(formatStatValue(5.0)).isEqualTo("5") + } + + @Test + fun `formatStatValue Double zero returns zero`() { + assertThat(formatStatValue(0.0)).isEqualTo("0") + } + + @Test + fun `formatStatValue Double fractional returns one decimal`() { + assertThat(formatStatValue(5.5)).isEqualTo("5.5") + } + + @Test + fun `formatStatValue Double negative value formats correctly`() { + assertThat(formatStatValue(-3.7)).isEqualTo("-3.7") + } + + @Test + fun `formatStatValue Double large value formats correctly`() { + assertThat(formatStatValue(1234.5)).isEqualTo("1234.5") + } + + @Test + fun `formatStatValue Double negative whole number returns no decimals`() { + assertThat(formatStatValue(-2.0)).isEqualTo("-2") + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt index fcc73b7cb2c8..4f72bede0df4 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt @@ -154,7 +154,7 @@ class YearInReviewViewModelTest : BaseUnitTest() { ) assertThat( (state as YearInReviewCardUiState.Error).message - ).isEqualTo("Test exception") + ).isEqualTo(UNKNOWN_ERROR) } @Test From 4f7a2cf585815c13d904cf9a4ea87f2c50e60036 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 14:14:30 +0100 Subject: [PATCH 08/14] Fix second round of review issues: stale success flag, race condition, siteId fallback, API type inconsistency - Reset isLoadedSuccessfully on error/exception so loadDataIfNeeded recovers after failed refresh - Add Mutex to InsightsCardsConfigurationRepository to prevent concurrent mutation races - Guard siteId in InsightsViewModel mutations to avoid operating with invalid 0L - Align fetchStatsInsights parameter from String to Long for interface consistency Co-Authored-By: Claude Opus 4.6 --- .../android/ui/newstats/InsightsViewModel.kt | 33 +++++-- .../ui/newstats/datasource/StatsDataSource.kt | 4 +- .../datasource/StatsDataSourceImpl.kt | 2 +- .../InsightsCardsConfigurationRepository.kt | 97 ++++++++++++------- .../ui/newstats/repository/StatsRepository.kt | 2 +- .../yearinreview/YearInReviewViewModel.kt | 2 + .../ui/newstats/InsightsViewModelTest.kt | 26 ++++- .../repository/StatsRepositoryInsightsTest.kt | 2 +- .../yearinreview/YearInReviewViewModelTest.kt | 48 +++++++++ 9 files changed, 162 insertions(+), 54 deletions(-) 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 0925a60964bf..54bc5a7cd0c8 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.InsightsCardsConfigurationRepository +import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper import javax.inject.Inject @@ -57,7 +58,14 @@ class InsightsViewModel @Inject constructor( } private fun loadConfiguration() { - val currentSiteId = siteId + val currentSiteId = selectedSiteRepository + .getSelectedSite()?.siteId ?: run { + AppLog.w( + AppLog.T.STATS, + "No site selected, skipping config load" + ) + return + } viewModelScope.launch { val config = cardConfigurationRepository .getConfiguration(currentSiteId) @@ -88,7 +96,7 @@ class InsightsViewModel @Inject constructor( } fun removeCard(cardType: InsightsCardType) { - val currentSiteId = siteId + val currentSiteId = resolvedSiteId() ?: return viewModelScope.launch { cardConfigurationRepository .removeCard(currentSiteId, cardType) @@ -96,7 +104,7 @@ class InsightsViewModel @Inject constructor( } fun addCard(cardType: InsightsCardType) { - val currentSiteId = siteId + val currentSiteId = resolvedSiteId() ?: return viewModelScope.launch { cardConfigurationRepository .addCard(currentSiteId, cardType) @@ -104,7 +112,7 @@ class InsightsViewModel @Inject constructor( } fun moveCardUp(cardType: InsightsCardType) { - val currentSiteId = siteId + val currentSiteId = resolvedSiteId() ?: return viewModelScope.launch { cardConfigurationRepository .moveCardUp(currentSiteId, cardType) @@ -112,7 +120,7 @@ class InsightsViewModel @Inject constructor( } fun moveCardToTop(cardType: InsightsCardType) { - val currentSiteId = siteId + val currentSiteId = resolvedSiteId() ?: return viewModelScope.launch { cardConfigurationRepository .moveCardToTop(currentSiteId, cardType) @@ -120,7 +128,7 @@ class InsightsViewModel @Inject constructor( } fun moveCardDown(cardType: InsightsCardType) { - val currentSiteId = siteId + val currentSiteId = resolvedSiteId() ?: return viewModelScope.launch { cardConfigurationRepository .moveCardDown(currentSiteId, cardType) @@ -128,10 +136,21 @@ class InsightsViewModel @Inject constructor( } fun moveCardToBottom(cardType: InsightsCardType) { - val currentSiteId = siteId + val currentSiteId = resolvedSiteId() ?: return viewModelScope.launch { cardConfigurationRepository .moveCardToBottom(currentSiteId, cardType) } } + + private fun resolvedSiteId(): Long? { + return selectedSiteRepository + .getSelectedSite()?.siteId ?: run { + AppLog.w( + AppLog.T.STATS, + "No site selected for card operation" + ) + null + } + } } 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 fec6775f104b..5837e506af0b 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 @@ -211,11 +211,11 @@ interface StatsDataSource { /** * Fetches stats insights for a specific site. * - * @param siteId The WordPress.com site ID as String + * @param siteId The WordPress.com site ID * @return Result containing the insights data or an error */ suspend fun fetchStatsInsights( - siteId: String + siteId: Long ): StatsInsightsDataResult } 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 51c395b03901..b65aa265f4b4 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 @@ -985,7 +985,7 @@ class StatsDataSourceImpl @Inject constructor( } override suspend fun fetchStatsInsights( - siteId: String + siteId: Long ): StatsInsightsDataResult { val result = getOrCreateClient() .request { requestBuilder -> 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 baeb607864c0..734a350f91f4 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 @@ -5,6 +5,8 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.ui.newstats.InsightsCardType @@ -21,6 +23,8 @@ class InsightsCardsConfigurationRepository @Inject constructor( private val appPrefsWrapper: AppPrefsWrapper, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher ) { + private val mutex = Mutex() + private val gson = GsonBuilder() .registerTypeAdapterFactory( EnumWithFallbackValueTypeAdapterFactory() @@ -54,36 +58,45 @@ class InsightsCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val newVisibleCards = current.visibleCards.toMutableList() - newVisibleCards.remove(cardType) - saveConfiguration( - siteId, - current.copy(visibleCards = newVisibleCards) - ) + mutex.withLock { + val current = getConfiguration(siteId) + val newVisibleCards = + current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } } suspend fun addCard( siteId: Long, cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - if (current.visibleCards.contains(cardType)) return@withContext - val newVisibleCards = current.visibleCards + cardType - saveConfiguration( - siteId, - current.copy(visibleCards = newVisibleCards) - ) + mutex.withLock { + val current = getConfiguration(siteId) + if (current.visibleCards.contains(cardType)) return@withLock + val newVisibleCards = current.visibleCards + cardType + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } } suspend fun moveCardUp( siteId: Long, cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) - if (index > 0) { - moveCardToIndex(siteId, current, cardType, index - 1) + mutex.withLock { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex( + siteId, current, cardType, index - 1 + ) + } } } @@ -91,10 +104,12 @@ class InsightsCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) - if (index > 0) { - moveCardToIndex(siteId, current, cardType, 0) + mutex.withLock { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex(siteId, current, cardType, 0) + } } } @@ -102,12 +117,16 @@ class InsightsCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) - if (index >= 0 && index < current.visibleCards.size - 1) { - moveCardToIndex( - siteId, current, cardType, index + 1 - ) + mutex.withLock { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index >= 0 && + index < current.visibleCards.size - 1 + ) { + moveCardToIndex( + siteId, current, cardType, index + 1 + ) + } } } @@ -115,15 +134,19 @@ class InsightsCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: InsightsCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = current.visibleCards.indexOf(cardType) - if (index >= 0 && index < current.visibleCards.size - 1) { - moveCardToIndex( - siteId, - current, - cardType, - current.visibleCards.size - 1 - ) + mutex.withLock { + val current = getConfiguration(siteId) + val index = current.visibleCards.indexOf(cardType) + if (index >= 0 && + index < current.visibleCards.size - 1 + ) { + moveCardToIndex( + siteId, + current, + cardType, + current.visibleCards.size - 1 + ) + } } } 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 c7d321b58ae1..ab7639733ff6 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 @@ -1327,7 +1327,7 @@ class StatsRepository @Inject constructor( siteId: Long ): InsightsResult = withContext(ioDispatcher) { val result = statsDataSource.fetchStatsInsights( - siteId = siteId.toString() + siteId = siteId ) when (result) { is StatsInsightsDataResult.Success -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt index 8287d3a0f775..7c92a9d766f3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -116,6 +116,7 @@ class YearInReviewViewModel @Inject constructor( ) } is InsightsResult.Error -> { + isLoadedSuccessfully = false _uiState.value = YearInReviewCardUiState.Error( message = resourceProvider @@ -127,6 +128,7 @@ class YearInReviewViewModel @Inject constructor( } } } catch (e: Exception) { + isLoadedSuccessfully = false AppLog.e( AppLog.T.STATS, "Error loading insights: ${e.message}", 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 c919780f8297..e3d0e572fa23 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.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest @@ -179,12 +180,9 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `when no site selected, then siteId defaults to 0`() = test { + fun `when no site selected, then config is not loaded`() = test { whenever(selectedSiteRepository.getSelectedSite()) .thenReturn(null) - whenever( - cardConfigurationRepository.getConfiguration(0L) - ).thenReturn(InsightsCardsConfiguration()) viewModel = InsightsViewModel( selectedSiteRepository, @@ -193,7 +191,25 @@ class InsightsViewModelTest : BaseUnitTest(StandardTestDispatcher()) { ) advanceUntilIdle() - verify(cardConfigurationRepository).getConfiguration(0L) + verify(cardConfigurationRepository, never()) + .getConfiguration(org.mockito.kotlin.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() + ) } @Test diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt index 36118718c772..a041dab18344 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt @@ -111,7 +111,7 @@ class StatsRepositoryInsightsTest : BaseUnitTest() { repository.fetchInsights(TEST_SITE_ID) verify(statsDataSource).fetchStatsInsights( - TEST_SITE_ID.toString() + TEST_SITE_ID ) } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt index 4f72bede0df4..aede1bf79f2c 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt @@ -451,6 +451,54 @@ class YearInReviewViewModelTest : BaseUnitTest() { assertThat(detailData).isEmpty() } + @Test + fun `when refresh fails after success, then loadDataIfNeeded reloads`() = + test { + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + + viewModel = YearInReviewViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf( + YearInReviewCardUiState.Loaded::class.java + ) + + whenever(statsRepository.fetchInsights(any())) + .thenReturn(InsightsResult.Error("Network error")) + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + + whenever(statsRepository.fetchInsights(any())) + .thenReturn( + InsightsResult.Success( + years = createTestYears() + ) + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf( + YearInReviewCardUiState.Loaded::class.java + ) + verify(statsRepository, times(3)) + .fetchInsights(eq(TEST_SITE_ID)) + } + private fun createTestYears() = listOf( YearInsightsData( year = "2025", From 36ff8691c3dda39145d05b4cb1e8a0508fe55c68 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Mar 2026 17:51:37 +0100 Subject: [PATCH 09/14] Update Year in Review labels to match web app Card uses short labels (Words, Likes) without "Total" prefix. Detail screen uses exact web labels: Total posts, Total comments, Avg comments per post, Total likes, Avg likes per post, Total words, Avg words per post. Co-Authored-By: Claude Opus 4.6 --- .../android/ui/newstats/yearinreview/YearInReviewCard.kt | 4 ++-- .../newstats/yearinreview/YearInReviewDetailActivity.kt | 9 +++++---- WordPress/src/main/res/values/strings.xml | 6 ++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt index 295179723fa2..fbd642d59120 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -239,7 +239,7 @@ private fun LoadedContent( StatMiniCard( icon = Icons.AutoMirrored.Outlined.TextSnippet, labelRes = - R.string.stats_insights_total_words, + R.string.stats_insights_words, value = formatStatValue(year.totalWords), modifier = Modifier.weight(1f) ) @@ -253,7 +253,7 @@ private fun LoadedContent( StatMiniCard( icon = Icons.Outlined.StarOutline, labelRes = - R.string.stats_insights_total_likes, + R.string.stats_insights_likes, value = formatStatValue(year.totalLikes), modifier = Modifier.weight(1f) ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt index d9698081f285..c1f8ca8e3ada 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt @@ -158,7 +158,8 @@ private fun YearDetailSection(year: YearSummary) { modifier = Modifier.padding(bottom = 12.dp) ) StatRow( - labelRes = R.string.stats_insights_posts, + labelRes = + R.string.stats_insights_total_posts, value = formatStatValue(year.totalPosts) ) StatDivider() @@ -170,7 +171,7 @@ private fun YearDetailSection(year: YearSummary) { StatDivider() StatRow( labelRes = - R.string.stats_insights_average_comments, + R.string.stats_insights_avg_comments_per_post, value = formatStatValue(year.avgComments) ) StatDivider() @@ -182,7 +183,7 @@ private fun YearDetailSection(year: YearSummary) { StatDivider() StatRow( labelRes = - R.string.stats_insights_average_likes, + R.string.stats_insights_avg_likes_per_post, value = formatStatValue(year.avgLikes) ) StatDivider() @@ -194,7 +195,7 @@ private fun YearDetailSection(year: YearSummary) { StatDivider() StatRow( labelRes = - R.string.stats_insights_average_words, + R.string.stats_insights_avg_words_per_post, value = formatStatValue(year.avgWords) ) } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 3103502919fc..13afbd278f09 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1493,6 +1493,12 @@ %1$s lower than the previous 7-days Avg likes/post Total words + Words + Likes + Total posts + Avg comments per post + Avg likes per post + Avg words per post Avg words/post Best Day Best Hour From f3a65a990f017e53f0bcc8dd9d109daf9b8705cc Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Thu, 12 Mar 2026 11:32:27 +0100 Subject: [PATCH 10/14] CMM-1936: Add All-time Stats and Most Popular Day Insights cards (#22673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update wordpress-rs library hash Co-Authored-By: Claude Opus 4.6 * Add stats summary data layer with shared caching Add fetchStatsSummary endpoint and StatsSummaryData model to StatsDataSource. Implement Mutex-based caching in StatsRepository so All-time Stats and Most Popular Day cards share a single API call. Co-Authored-By: Claude Opus 4.6 * Add All-time Stats Insights card New card showing Views, Visitors, Posts, and Comments from the statsSummary endpoint. Uses rememberShimmerBrush for loading state. Co-Authored-By: Claude Opus 4.6 * Add Most Popular Day Insights card New card showing the best day for views with date, view count, and percentage. Shares the statsSummary API call with All-time Stats via repository caching. Co-Authored-By: Claude Opus 4.6 * Wire All-time Stats and Most Popular Day into Insights tab Add card types to InsightsCardType enum and default card list. Wire ViewModels and cards into InsightsTabContent. Add config migration to automatically show new card types for existing users. Co-Authored-By: Claude Opus 4.6 * Fix review issues: locale, empty best day, config migration, tests - Create DateTimeFormatter at format time instead of caching in companion object to respect locale changes - Handle empty viewsBestDay by returning loaded state with empty values instead of throwing - Fix config migration by persisting hiddenCards field so new card types can be distinguished from user-removed cards - Add missing tests: empty viewsBestDay, zero views percentage, exception path, dayAndMonth assertion, addNewCardTypes migration, full config no-migration Co-Authored-By: Claude Opus 4.6 * Update RS library and fix insights locale, detekt, and date casing - Update wordpress-rs to 1de57afce924622700bcc8d3a1f3ce893d8dad5b - Add locale parameter to StatsInsightsParams matching other endpoints - Fix detekt MagicNumber and TooGenericExceptionCaught violations - Capitalize first letter of formatted date in Most Popular Day card Co-Authored-By: Claude Opus 4.6 * Move stats summary cache from StatsRepository to InsightsViewModel StatsRepository was not @Singleton, so the shared cache for statsSummary data was broken — each ViewModel got its own instance. Move caching to InsightsViewModel which is activity-scoped and shared. Child ViewModels now receive a summary provider function wired from the UI layer. Co-Authored-By: Claude Opus 4.6 * Fix double-write and concurrency in InsightsCardsConfigurationRepository addNewCardTypes wrote to prefs directly, then the caller wrote again via saveConfiguration. Also getConfiguration was not mutex-protected. Make addNewCardTypes pure, extract loadAndMigrate for atomic read-migrate-save within the mutex, and make getConfiguration go through the mutex. Co-Authored-By: Claude Opus 4.6 * Rename hiddenCards() to computeHiddenCards() to avoid naming conflict InsightsCardsConfiguration had both a hiddenCards property (persisted list) and a hiddenCards() method (computed from visibleCards). Rename the method to make the distinction clear. Co-Authored-By: Claude Opus 4.6 * Add NoData state for MostPopularDay and fix percentage precision When a site has no best day data, show a dedicated NoData state with a "No data yet" message instead of a blank Loaded card. Also reduce percentage format from 3 to 1 decimal place for consistency with typical stats UIs. Co-Authored-By: Claude Opus 4.6 * Suppress TooGenericExceptionThrown detekt warning in test files Co-Authored-By: Claude Opus 4.6 * Fix thread-safety, DI design, and Error state in Insights cards - Add @Volatile to isLoading/isLoadedSuccessfully flags in ViewModels - Extract StatsSummaryUseCase singleton to replace summaryProvider var, removing temporal coupling and null-check error paths - Change Error from class to data class, move onRetry out of state and into composable parameters to avoid unnecessary recompositions Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../android/ui/newstats/InsightsCardType.kt | 19 +- .../ui/newstats/InsightsCardsConfiguration.kt | 5 +- .../android/ui/newstats/InsightsViewModel.kt | 8 +- .../android/ui/newstats/NewStatsActivity.kt | 136 +++++- .../newstats/alltimestats/AllTimeStatsCard.kt | 362 +++++++++++++++ .../alltimestats/AllTimeStatsCardUiState.kt | 16 + .../alltimestats/AllTimeStatsViewModel.kt | 142 ++++++ .../ui/newstats/datasource/StatsDataSource.kt | 34 ++ .../datasource/StatsDataSourceImpl.kt | 55 ++- .../mostpopularday/MostPopularDayCard.kt | 434 ++++++++++++++++++ .../MostPopularDayCardUiState.kt | 18 + .../mostpopularday/MostPopularDayViewModel.kt | 206 +++++++++ .../InsightsCardsConfigurationRepository.kt | 172 +++++-- .../ui/newstats/repository/StatsRepository.kt | 41 +- .../repository/StatsSummaryUseCase.kt | 47 ++ WordPress/src/main/res/values/strings.xml | 4 + .../InsightsCardsConfigurationTest.kt | 8 +- .../ui/newstats/InsightsViewModelTest.kt | 245 ++++++---- .../alltimestats/AllTimeStatsViewModelTest.kt | 407 ++++++++++++++++ .../MostPopularDayViewModelTest.kt | 327 +++++++++++++ ...nsightsCardsConfigurationRepositoryTest.kt | 268 +++++++---- .../repository/StatsSummaryUseCaseTest.kt | 153 ++++++ gradle/libs.versions.toml | 2 +- 23 files changed, 2869 insertions(+), 240 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt 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' From 259a5ac76295c6e2a6cfbda5be924a303962bb82 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Fri, 13 Mar 2026 11:32:48 +0100 Subject: [PATCH 11/14] Add Most Popular Time card and centralize Insights data fetching (#22684) * Add Most Popular Time Insights card Introduce a new "Most popular time" card in the stats insights tab that shows the best day of week and best hour with their view percentages. The card reuses the insights API endpoint via a new shared StatsInsightsUseCase (following the StatsSummaryUseCase caching pattern), which also refactors YearInReviewViewModel to use the same use case. Co-Authored-By: Claude Opus 4.6 * Clean up MostPopularTimeViewModel: locale-aware hour formatting and remove unnecessary @Volatile Use DateTimeFormatter.ofLocalizedTime instead of hardcoded AM/PM to respect device locale settings. Remove unnecessary @Volatile annotations since all access is on the main thread via viewModelScope. Co-Authored-By: Claude Opus 4.6 * Add tests for MostPopularTimeViewModel and StatsInsightsUseCase Co-Authored-By: Claude Opus 4.6 * Fix day-of-week mapping, NoData condition, and add bounds check - Fix day mapping: WordPress API uses 0=Monday (not Sunday) - Show NoData when either day or hour percent is zero (not both) - Add bounds check for invalid day values (returns empty string) - Update and add tests for new behavior Co-Authored-By: Claude Opus 4.6 * Fix detekt: suppress LongMethod and remove unused import Co-Authored-By: Claude Opus 4.6 * Centralize Insights data fetching in InsightsViewModel Move data fetching from individual card ViewModels to InsightsViewModel as coordinator, ensuring each API endpoint (stats summary and insights) is called only once per load. Card ViewModels now receive results via SharedFlow instead of fetching independently, reducing duplicate network calls from 4 to 2. Co-Authored-By: Claude Opus 4.6 * Fix race condition, consistent onRetry pattern, and remove unused siteId - Set isDataLoading in refreshData() to prevent duplicate fetches - Move onRetry from YearInReviewCardUiState.Error to composable param - Remove unused siteId property, use resolvedSiteId() directly Co-Authored-By: Claude Opus 4.6 * Add formatHour bounds check, remove duplicate string, clean up import - Guard formatHour against invalid hour values (crash prevention) - Remove duplicate stats_insights_percent_of_views string resource - Use import for kotlin.math.round instead of fully qualified call Co-Authored-By: Claude Opus 4.6 * Fix detekt LongMethod: extract fetchSummary and fetchInsights Co-Authored-By: Claude Opus 4.6 * Reduce duplication in MostPopularTimeCard using shared components Replace manual card container, header, error content, and shimmer boxes with StatsCardContainer, StatsCardHeader, StatsCardErrorContent, and ShimmerBox. Extract repeated day/hour section into StatSection. Co-Authored-By: Claude Opus 4.6 * Rename views percent string resource and add NoData preview - Rename stats_insights_most_popular_day_percent to stats_insights_views_percent for neutral naming - Add missing NoData preview to MostPopularTimeCard Co-Authored-By: Claude Opus 4.6 * Trigger PR checks * Use device 24h/12h setting for hour formatting Use android.text.format.DateFormat.is24HourFormat() to respect the device time format preference instead of relying on locale. Co-Authored-By: Claude Opus 4.6 * Fix thread safety, CancellationException handling, and lambda allocation - Add @Volatile to isDataLoaded/isDataLoading flags - Rethrow CancellationException to preserve structured concurrency - Wrap onRetryData lambda with remember to avoid recomposition allocations Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../android/ui/newstats/InsightsCardType.kt | 6 +- .../android/ui/newstats/InsightsViewModel.kt | 142 ++++- .../android/ui/newstats/NewStatsActivity.kt | 149 +++-- .../alltimestats/AllTimeStatsViewModel.kt | 128 +--- .../ui/newstats/datasource/StatsDataSource.kt | 4 + .../datasource/StatsDataSourceImpl.kt | 10 + .../mostpopularday/MostPopularDayCard.kt | 2 +- .../mostpopularday/MostPopularDayViewModel.kt | 118 +--- .../mostpopulartime/MostPopularTimeCard.kt | 307 +++++++++ .../MostPopularTimeCardUiState.kt | 18 + .../MostPopularTimeViewModel.kt | 128 ++++ .../repository/StatsInsightsUseCase.kt | 47 ++ .../ui/newstats/repository/StatsRepository.kt | 6 +- .../newstats/yearinreview/YearInReviewCard.kt | 17 +- .../yearinreview/YearInReviewCardUiState.kt | 5 +- .../yearinreview/YearInReviewViewModel.kt | 137 +---- WordPress/src/main/res/values/strings.xml | 3 +- .../InsightsCardsConfigurationTest.kt | 1 + .../ui/newstats/InsightsViewModelTest.kt | 259 +++++++- .../alltimestats/AllTimeStatsViewModelTest.kt | 384 ++---------- .../MostPopularDayViewModelTest.kt | 236 ++----- .../MostPopularTimeViewModelTest.kt | 354 +++++++++++ ...nsightsCardsConfigurationRepositoryTest.kt | 25 +- .../repository/StatsInsightsUseCaseTest.kt | 233 +++++++ .../repository/StatsRepositoryInsightsTest.kt | 32 +- .../yearinreview/YearInReviewViewModelTest.kt | 582 +++++------------- 26 files changed, 1925 insertions(+), 1408 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt 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 3ce8230f8a09..1303c62c6113 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 @@ -14,6 +14,9 @@ enum class InsightsCardType( ), MOST_POPULAR_DAY( R.string.stats_insights_most_popular_day + ), + MOST_POPULAR_TIME( + R.string.stats_insights_most_popular_time ); companion object { @@ -21,7 +24,8 @@ enum class InsightsCardType( listOf( YEAR_IN_REVIEW, ALL_TIME_STATS, - MOST_POPULAR_DAY + MOST_POPULAR_DAY, + MOST_POPULAR_TIME ) } } 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 f993b6736c1f..de63a40f02d4 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 @@ -3,14 +3,23 @@ package org.wordpress.android.ui.newstats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.InsightsCardsConfigurationRepository +import org.wordpress.android.ui.newstats.repository.InsightsResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase +import org.wordpress.android.ui.newstats.repository.StatsInsightsUseCase import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper +import kotlin.coroutines.cancellation.CancellationException import javax.inject.Inject @HiltViewModel @@ -19,7 +28,9 @@ class InsightsViewModel @Inject constructor( SelectedSiteRepository, private val cardConfigurationRepository: InsightsCardsConfigurationRepository, - private val networkUtilsWrapper: NetworkUtilsWrapper + private val networkUtilsWrapper: NetworkUtilsWrapper, + private val statsSummaryUseCase: StatsSummaryUseCase, + private val statsInsightsUseCase: StatsInsightsUseCase ) : ViewModel() { private val _visibleCards = MutableStateFlow>( @@ -42,9 +53,26 @@ class InsightsViewModel @Inject constructor( val cardsToLoad: StateFlow> = _cardsToLoad.asStateFlow() - private val siteId: Long - get() = selectedSiteRepository - .getSelectedSite()?.siteId ?: 0L + // Data fetching coordination + private val _summaryResult = + MutableSharedFlow(replay = 1) + val summaryResult: SharedFlow = + _summaryResult.asSharedFlow() + + private val _insightsResult = + MutableSharedFlow(replay = 1) + val insightsResult: SharedFlow = + _insightsResult.asSharedFlow() + + private val _isDataRefreshing = MutableStateFlow(false) + val isDataRefreshing: StateFlow = + _isDataRefreshing.asStateFlow() + + @Volatile + private var isDataLoaded = false + + @Volatile + private var isDataLoading = false init { checkNetworkStatus() @@ -59,6 +87,107 @@ class InsightsViewModel @Inject constructor( return isAvailable } + // region Data fetching + + fun loadDataIfNeeded() { + if (isDataLoaded || isDataLoading) return + isDataLoading = true + fetchData() + } + + fun fetchData(forceRefresh: Boolean = false) { + val siteId = resolvedSiteId() ?: run { + isDataLoading = false + _isDataRefreshing.value = false + return + } + viewModelScope.launch { + try { + coroutineScope { + launch { + fetchSummary(siteId, forceRefresh) + } + launch { + fetchInsights(siteId, forceRefresh) + } + } + isDataLoaded = true + } finally { + isDataLoading = false + _isDataRefreshing.value = false + } + } + } + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + private suspend fun fetchSummary( + siteId: Long, + forceRefresh: Boolean + ) { + try { + val result = statsSummaryUseCase( + siteId, forceRefresh + ) + _summaryResult.emit(result) + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching stats summary:" + + " ${e.message}", + e + ) + _summaryResult.emit( + StatsSummaryResult.Error( + e.message ?: "Unknown error" + ) + ) + } + } + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + private suspend fun fetchInsights( + siteId: Long, + forceRefresh: Boolean + ) { + try { + val result = statsInsightsUseCase( + siteId, forceRefresh + ) + _insightsResult.emit(result) + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching insights:" + + " ${e.message}", + e + ) + _insightsResult.emit( + InsightsResult.Error( + e.message ?: "Unknown error" + ) + ) + } + } + + fun refreshData() { + isDataLoaded = false + isDataLoading = true + _isDataRefreshing.value = true + fetchData(forceRefresh = true) + } + + // endregion + + // region Card configuration + private fun loadConfiguration() { val currentSiteId = selectedSiteRepository .getSelectedSite()?.siteId ?: run { @@ -79,7 +208,8 @@ class InsightsViewModel @Inject constructor( viewModelScope.launch { cardConfigurationRepository.configurationFlow .collect { pair -> - val currentSiteId = siteId + val currentSiteId = + resolvedSiteId() ?: return@collect if (pair != null && pair.first == currentSiteId ) { @@ -145,6 +275,8 @@ class InsightsViewModel @Inject constructor( } } + // endregion + private fun resolvedSiteId(): Long? { return selectedSiteRepository .getSelectedSite()?.siteId ?: run { 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 b2930df88602..f0a8d2078418 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 @@ -103,6 +103,8 @@ 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.mostpopulartime.MostPopularTimeCard +import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeViewModel import org.wordpress.android.ui.newstats.yearinreview.YearInReviewCard import org.wordpress.android.ui.newstats.yearinreview.YearInReviewDetailActivity import org.wordpress.android.ui.newstats.yearinreview.YearInReviewViewModel @@ -886,6 +888,8 @@ private fun InsightsTabContent( viewModel(), mostPopularDayViewModel: MostPopularDayViewModel = viewModel(), + mostPopularTimeViewModel: MostPopularTimeViewModel = + viewModel(), insightsViewModel: InsightsViewModel = viewModel() ) { val context = LocalContext.current @@ -896,15 +900,11 @@ private fun InsightsTabContent( 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 mostPopularTimeUiState by + mostPopularTimeViewModel + .uiState.collectAsState() + val isRefreshing by insightsViewModel + .isDataRefreshing.collectAsState() val pullToRefreshState = rememberPullToRefreshState() val visibleCards by insightsViewModel @@ -919,20 +919,25 @@ private fun InsightsTabContent( val addCardSheetState = rememberModalBottomSheetState() LaunchedEffect(cardsToLoad) { - cardsToLoad.dispatchInsightsToVisibleCards( - onYearInReview = { - yearInReviewViewModel - .loadDataIfNeeded() - }, - onAllTimeStats = { - allTimeStatsViewModel - .loadDataIfNeeded() - }, - onMostPopularDay = { - mostPopularDayViewModel - .loadDataIfNeeded() - } - ) + insightsViewModel.loadDataIfNeeded() + } + + val onRetryData = remember { + { insightsViewModel.fetchData() } + } + + LaunchedEffect(Unit) { + insightsViewModel.summaryResult.collect { result -> + allTimeStatsViewModel.handleResult(result) + mostPopularDayViewModel.handleResult(result) + } + } + + LaunchedEffect(Unit) { + insightsViewModel.insightsResult.collect { result -> + yearInReviewViewModel.handleResult(result) + mostPopularTimeViewModel.handleResult(result) + } } if (showAddCardSheet) { @@ -950,24 +955,10 @@ private fun InsightsTabContent( mutableStateOf(!isNetworkAvailable) } - val loadVisibleCards = { - visibleCards.dispatchInsightsToVisibleCards( - onYearInReview = { - yearInReviewViewModel.loadData() - }, - onAllTimeStats = { - allTimeStatsViewModel.loadData() - }, - onMostPopularDay = { - mostPopularDayViewModel.loadData() - } - ) - } - LaunchedEffect(isNetworkAvailable) { if (isNetworkAvailable && showNoConnectionScreen) { showNoConnectionScreen = false - loadVisibleCards() + insightsViewModel.fetchData() } else if (!isNetworkAvailable && !showNoConnectionScreen ) { @@ -982,7 +973,7 @@ private fun InsightsTabContent( insightsViewModel.checkNetworkStatus() if (isAvailable) { showNoConnectionScreen = false - loadVisibleCards() + insightsViewModel.fetchData() } } ) @@ -995,17 +986,7 @@ private fun InsightsTabContent( state = pullToRefreshState, onRefresh = { insightsViewModel.checkNetworkStatus() - visibleCards.dispatchInsightsToVisibleCards( - onYearInReview = { - yearInReviewViewModel.refresh() - }, - onAllTimeStats = { - allTimeStatsViewModel.refresh() - }, - onMostPopularDay = { - mostPopularDayViewModel.refresh() - } - ) + insightsViewModel.refreshData() }, indicator = { PullToRefreshDefaults.Indicator( @@ -1062,7 +1043,8 @@ private fun InsightsTabContent( }, onRetry = { allTimeStatsViewModel - .onRetry() + .showLoading() + onRetryData() }, cardPosition = cardPosition, onMoveUp = { @@ -1096,7 +1078,50 @@ private fun InsightsTabContent( }, onRetry = { mostPopularDayViewModel - .onRetry() + .showLoading() + onRetryData() + }, + cardPosition = + cardPosition, + onMoveUp = { + insightsViewModel + .moveCardUp( + cardType + ) + }, + onMoveToTop = { + insightsViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + insightsViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + insightsViewModel + .moveCardToBottom( + cardType + ) + } + ) + InsightsCardType.MOST_POPULAR_TIME -> + MostPopularTimeCard( + uiState = + mostPopularTimeUiState, + onRemoveCard = { + insightsViewModel + .removeCard( + cardType + ) + }, + onRetry = { + mostPopularTimeViewModel + .showLoading() + onRetryData() }, cardPosition = cardPosition, @@ -1139,6 +1164,11 @@ private fun InsightsTabContent( YearInReviewDetailActivity .start(context, years) }, + onRetry = { + yearInReviewViewModel + .showLoading() + onRetryData() + }, cardPosition = cardPosition, onMoveUp = { insightsViewModel @@ -1168,21 +1198,6 @@ private fun InsightsTabContent( } } -private fun List.dispatchInsightsToVisibleCards( - 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) @Composable 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 index 1c633eb26cca..c838bd176565 100644 --- 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 @@ -1,26 +1,18 @@ 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 + private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -29,114 +21,26 @@ class AllTimeStatsViewModel @Inject constructor( 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 + fun handleResult(result: StatsSummaryResult) { + _uiState.value = when (result) { + is StatsSummaryResult.Success -> + AllTimeStatsCardUiState.Loaded( + views = result.data.views, + visitors = result.data.visitors, + posts = result.data.posts, + comments = result.data.comments ) - } 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 = + is StatsSummaryResult.Error -> AllTimeStatsCardUiState.Error( - message = resourceProvider.getString( - R.string.stats_error_unknown - ) + message = resourceProvider + .getString( + R.string.stats_error_api + ) ) } } - fun onRetry() { - loadData() + fun showLoading() { + _uiState.value = AllTimeStatsCardUiState.Loading } } 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 9a8a55adf66b..31ad6cd2910d 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 @@ -566,6 +566,10 @@ sealed class StatsInsightsDataResult { * Stats insights data from the API. */ data class StatsInsightsData( + val highestHour: Int, + val highestHourPercent: Double, + val highestDayOfWeek: Int, + val highestDayPercent: Double, val years: List ) 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 b071982a4372..d16db5d0c8ac 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 @@ -984,6 +984,7 @@ class StatsDataSourceImpl @Inject constructor( } } + @Suppress("LongMethod") override suspend fun fetchStatsInsights( siteId: Long ): StatsInsightsDataResult { @@ -1012,6 +1013,15 @@ class StatsDataSourceImpl @Inject constructor( ) StatsInsightsDataResult.Success( StatsInsightsData( + highestHour = + data.highestHour.toInt(), + highestHourPercent = + data.highestHourPercent, + highestDayOfWeek = + data.highestDayOfWeek + .toInt(), + highestDayPercent = + data.highestDayPercent, years = years.map { yearData -> YearInsightsData( year = yearData.year, 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 index a64ed7080ce0..07876e376ceb 100644 --- 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 @@ -313,7 +313,7 @@ private fun LoadedContent( Text( text = stringResource( R.string - .stats_insights_most_popular_day_percent, + .stats_insights_views_percent, state.viewsPercentage ), style = MaterialTheme.typography.bodyMedium, 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 index 11c3012ce05c..675a044b9fb5 100644 --- 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 @@ -1,18 +1,13 @@ 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 @@ -21,10 +16,7 @@ import javax.inject.Inject @HiltViewModel class MostPopularDayViewModel @Inject constructor( - private val selectedSiteRepository: - SelectedSiteRepository, - private val resourceProvider: ResourceProvider, - private val statsSummaryUseCase: StatsSummaryUseCase + private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -33,110 +25,22 @@ class MostPopularDayViewModel @Inject constructor( 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 = + fun handleResult(result: StatsSummaryResult) { + _uiState.value = when (result) { + is StatsSummaryResult.Success -> + mapToUiState(result.data) + is StatsSummaryResult.Error -> 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 - ) + 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() + fun showLoading() { + _uiState.value = MostPopularDayCardUiState.Loading } companion object { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCard.kt new file mode 100644 index 000000000000..fe648705ca53 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCard.kt @@ -0,0 +1,307 @@ +package org.wordpress.android.ui.newstats.mostpopulartime + +import androidx.compose.foundation.layout.Column +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.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.StatsCardContainer +import org.wordpress.android.ui.newstats.components.StatsCardErrorContent +import org.wordpress.android.ui.newstats.components.StatsCardHeader +import org.wordpress.android.ui.newstats.util.ShimmerBox + +private val CardPadding = 16.dp + +@Composable +@Suppress("LongParameterList") +fun MostPopularTimeCard( + uiState: MostPopularTimeCardUiState, + onRemoveCard: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + when (uiState) { + is MostPopularTimeCardUiState.Loading -> + LoadingContent() + is MostPopularTimeCardUiState.NoData -> + NoDataContent( + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is MostPopularTimeCardUiState.Loaded -> + LoadedContent( + uiState, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is MostPopularTimeCardUiState.Error -> + StatsCardErrorContent( + titleResId = R.string + .stats_insights_most_popular_time, + errorMessageResId = + R.string.stats_error_api, + onRetry = onRetry, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + ShimmerBox( + modifier = Modifier + .width(180.dp) + .height(24.dp) + ) + Spacer(modifier = Modifier.height(20.dp)) + ShimmerBox( + modifier = Modifier + .width(60.dp) + .height(14.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + ShimmerBox( + modifier = Modifier + .width(120.dp) + .height(32.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + ShimmerBox( + modifier = Modifier + .width(80.dp) + .height(14.dp) + ) + Spacer(modifier = Modifier.height(20.dp)) + ShimmerBox( + modifier = Modifier + .width(60.dp) + .height(14.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + ShimmerBox( + modifier = Modifier + .width(100.dp) + .height(32.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + ShimmerBox( + modifier = Modifier + .width(80.dp) + .height(14.dp) + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun NoDataContent( + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = R.string + .stats_insights_most_popular_time, + onRemoveCard = 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: MostPopularTimeCardUiState.Loaded, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = R.string + .stats_insights_most_popular_time, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + StatSection( + label = stringResource( + R.string.stats_insights_best_day + ), + value = state.bestDay, + percent = stringResource( + R.string + .stats_insights_views_percent, + state.bestDayPercent + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + StatSection( + label = stringResource( + R.string.stats_insights_best_hour + ), + value = state.bestHour, + percent = stringResource( + R.string + .stats_insights_views_percent, + state.bestHourPercent + ) + ) + } +} + +@Composable +private fun StatSection( + label: String, + value: String, + percent: String +) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = percent, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant + ) +} + +@Preview(showBackground = true) +@Composable +private fun MostPopularTimeCardLoadingPreview() { + AppThemeM3 { + MostPopularTimeCard( + uiState = + MostPopularTimeCardUiState.Loading, + onRemoveCard = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MostPopularTimeCardNoDataPreview() { + AppThemeM3 { + MostPopularTimeCard( + uiState = + MostPopularTimeCardUiState.NoData, + onRemoveCard = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MostPopularTimeCardLoadedPreview() { + AppThemeM3 { + MostPopularTimeCard( + uiState = + MostPopularTimeCardUiState.Loaded( + bestDay = "Monday", + bestDayPercent = "23", + bestHour = "4:00 PM", + bestHourPercent = "11" + ), + onRemoveCard = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MostPopularTimeCardErrorPreview() { + AppThemeM3 { + MostPopularTimeCard( + uiState = MostPopularTimeCardUiState.Error( + message = "Failed to load stats" + ), + onRemoveCard = {}, + onRetry = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCardUiState.kt new file mode 100644 index 000000000000..7de7a75bfa40 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeCardUiState.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.ui.newstats.mostpopulartime + +sealed class MostPopularTimeCardUiState { + data object Loading : MostPopularTimeCardUiState() + + data object NoData : MostPopularTimeCardUiState() + + data class Loaded( + val bestDay: String, + val bestDayPercent: String, + val bestHour: String, + val bestHourPercent: String + ) : MostPopularTimeCardUiState() + + data class Error( + val message: String + ) : MostPopularTimeCardUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModel.kt new file mode 100644 index 000000000000..92ccc8130658 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModel.kt @@ -0,0 +1,128 @@ +package org.wordpress.android.ui.newstats.mostpopulartime + +import android.content.Context +import android.text.format.DateFormat +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.datasource.StatsInsightsData +import org.wordpress.android.ui.newstats.repository.InsightsResult +import org.wordpress.android.viewmodel.ResourceProvider +import java.time.DayOfWeek +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Locale +import javax.inject.Inject +import kotlin.math.round + +@HiltViewModel +class MostPopularTimeViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + MostPopularTimeCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + fun handleResult(result: InsightsResult) { + val use24Hour = + DateFormat.is24HourFormat(context) + _uiState.value = when (result) { + is InsightsResult.Success -> + mapToUiState(result.data, use24Hour) + is InsightsResult.Error -> + MostPopularTimeCardUiState.Error( + message = resourceProvider + .getString( + R.string.stats_error_api + ) + ) + } + } + + fun showLoading() { + _uiState.value = MostPopularTimeCardUiState.Loading + } + + companion object { + internal fun mapToUiState( + data: StatsInsightsData, + use24HourFormat: Boolean + ): MostPopularTimeCardUiState { + if (data.highestDayPercent == 0.0 || + data.highestHourPercent == 0.0 + ) { + return MostPopularTimeCardUiState.NoData + } + return MostPopularTimeCardUiState.Loaded( + bestDay = formatDayOfWeek( + data.highestDayOfWeek + ), + bestDayPercent = formatPercent( + data.highestDayPercent + ), + bestHour = formatHour( + data.highestHour, + use24HourFormat + ), + bestHourPercent = formatPercent( + data.highestHourPercent + ) + ) + } + + private const val DAYS_IN_WEEK = 7 + + private fun formatDayOfWeek( + wpDayOfWeek: Int + ): String { + // WordPress API: 0=Monday, 6=Sunday + if (wpDayOfWeek !in 0 until DAYS_IN_WEEK) { + return "" + } + val dayOfWeek = + DayOfWeek.of( + (wpDayOfWeek % DAYS_IN_WEEK) + 1 + ) + return dayOfWeek.getDisplayName( + TextStyle.FULL, + Locale.getDefault() + ) + } + + private const val MAX_HOUR = 23 + + private fun formatHour( + hour: Int, + use24HourFormat: Boolean + ): String { + if (hour !in 0..MAX_HOUR) return "" + val time = LocalTime.of(hour, 0) + val pattern = if (use24HourFormat) { + "HH:mm" + } else { + "h:mm a" + } + return time.format( + DateTimeFormatter.ofPattern( + pattern, Locale.getDefault() + ) + ) + } + + private fun formatPercent( + percent: Double + ): String { + return round(percent) + .toInt().toString() + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt new file mode 100644 index 000000000000..49dc9bc69331 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.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.StatsInsightsData +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatsInsightsUseCase @Inject constructor( + private val statsRepository: StatsRepository, + private val accountStore: AccountStore +) { + private val mutex = Mutex() + private var cachedInsights: + Pair? = null + + suspend operator fun invoke( + siteId: Long, + forceRefresh: Boolean = false + ): InsightsResult { + val token = accountStore.accessToken + if (token.isNullOrEmpty()) { + return InsightsResult.Error( + "No access token" + ) + } + statsRepository.init(token) + return mutex.withLock { + val cached = cachedInsights + if (!forceRefresh && + cached != null && + cached.first == siteId + ) { + return@withLock InsightsResult + .Success(cached.second) + } + val result = + statsRepository.fetchInsights(siteId) + if (result is InsightsResult.Success) { + cachedInsights = siteId to result.data + } + result + } + } +} 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 07b729c67961..11103514c890 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 @@ -12,10 +12,10 @@ import org.wordpress.android.ui.newstats.datasource.ReferrersDataResult 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.StatsInsightsData 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 import org.wordpress.android.ui.newstats.datasource.StatsVisitsData @@ -1333,7 +1333,7 @@ class StatsRepository @Inject constructor( when (result) { is StatsInsightsDataResult.Success -> InsightsResult.Success( - years = result.data.years + data = result.data ) is StatsInsightsDataResult.Error -> { appLogWrapper.e( @@ -1761,7 +1761,7 @@ data class DeviceItemData( */ sealed class InsightsResult { data class Success( - val years: List + val data: StatsInsightsData ) : InsightsResult() data class Error( val message: String diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt index fbd642d59120..37e1e951a117 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -59,6 +59,7 @@ fun YearInReviewCard( uiState: YearInReviewCardUiState, onRemoveCard: () -> Unit, onShowAllClick: () -> Unit, + onRetry: () -> Unit, modifier: Modifier = Modifier, cardPosition: CardPosition? = null, onMoveUp: (() -> Unit)? = null, @@ -98,6 +99,7 @@ fun YearInReviewCard( ErrorContent( uiState, onRemoveCard, + onRetry, cardPosition, onMoveUp, onMoveToTop, @@ -322,6 +324,7 @@ private fun StatMiniCard( private fun ErrorContent( state: YearInReviewCardUiState.Error, onRemoveCard: () -> Unit, + onRetry: () -> Unit, cardPosition: CardPosition?, onMoveUp: (() -> Unit)?, onMoveToTop: (() -> Unit)?, @@ -365,7 +368,7 @@ private fun ErrorContent( color = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = state.onRetry) { + Button(onClick = onRetry) { Text( text = stringResource(R.string.retry) ) @@ -381,7 +384,8 @@ private fun YearInReviewCardLoadingPreview() { YearInReviewCard( uiState = YearInReviewCardUiState.Loading, onRemoveCard = {}, - onShowAllClick = {} + onShowAllClick = {}, + onRetry = {} ) } } @@ -406,7 +410,8 @@ private fun YearInReviewCardLoadedPreview() { ) ), onRemoveCard = {}, - onShowAllClick = {} + onShowAllClick = {}, + onRetry = {} ) } } @@ -417,11 +422,11 @@ private fun YearInReviewCardErrorPreview() { AppThemeM3 { YearInReviewCard( uiState = YearInReviewCardUiState.Error( - message = "Failed to load stats", - onRetry = {} + message = "Failed to load stats" ), onRemoveCard = {}, - onShowAllClick = {} + onShowAllClick = {}, + onRetry = {} ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt index b89d4458a618..fa778bba5516 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt @@ -10,9 +10,8 @@ sealed class YearInReviewCardUiState { val years: List ) : YearInReviewCardUiState() - class Error( - val message: String, - val onRetry: () -> Unit + data class Error( + val message: String ) : YearInReviewCardUiState() } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt index 7c92a9d766f3..09986360a7f4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -1,29 +1,19 @@ package org.wordpress.android.ui.newstats.yearinreview import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.wordpress.android.R -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.AccountStore -import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.datasource.YearInsightsData import org.wordpress.android.ui.newstats.repository.InsightsResult -import org.wordpress.android.ui.newstats.repository.StatsRepository -import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider import java.time.Year import javax.inject.Inject @HiltViewModel class YearInReviewViewModel @Inject constructor( - private val selectedSiteRepository: SelectedSiteRepository, - private val accountStore: AccountStore, - private val statsRepository: StatsRepository, private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = @@ -33,118 +23,31 @@ class YearInReviewViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() - private val _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = - _isRefreshing.asStateFlow() - - private var isLoading = false - 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) - } finally { - _isRefreshing.value = false - } - } - } - - fun loadData() { - val site = selectedSiteRepository.getSelectedSite() - if (site == null) { - isLoading = false - _uiState.value = YearInReviewCardUiState.Error( - message = resourceProvider.getString( - R.string.stats_error_no_site - ), - onRetry = ::loadData - ) - return - } - - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) { - isLoading = false - _uiState.value = YearInReviewCardUiState.Error( - message = resourceProvider.getString( - R.string.stats_error_api - ), - onRetry = ::loadData - ) - return - } - - statsRepository.init(accessToken) - _uiState.value = YearInReviewCardUiState.Loading - - viewModelScope.launch { - try { - loadDataInternal(site) - } finally { - isLoading = false + fun handleResult(result: InsightsResult) { + _uiState.value = when (result) { + is InsightsResult.Success -> { + val years = result.data.years + .map { it.toUiModel() } + .ensureCurrentYear() + .sortedByDescending { + it.year + } + YearInReviewCardUiState.Loaded( + years = years + ) } - } - } - - @Suppress("TooGenericExceptionCaught") - private suspend fun loadDataInternal(site: SiteModel) { - try { - val result = statsRepository - .fetchInsights(site.siteId) - when (result) { - is InsightsResult.Success -> { - isLoadedSuccessfully = true - val years = result.years - .map { it.toUiModel() } - .ensureCurrentYear() - .sortedByDescending { - it.year - } - _uiState.value = - YearInReviewCardUiState.Loaded( - years = years + is InsightsResult.Error -> + YearInReviewCardUiState.Error( + message = resourceProvider + .getString( + R.string.stats_error_api ) - } - is InsightsResult.Error -> { - isLoadedSuccessfully = false - _uiState.value = - YearInReviewCardUiState.Error( - message = resourceProvider - .getString( - R.string.stats_error_api - ), - onRetry = ::loadData - ) - } - } - } catch (e: Exception) { - isLoadedSuccessfully = false - AppLog.e( - AppLog.T.STATS, - "Error loading insights: ${e.message}", - e - ) - _uiState.value = YearInReviewCardUiState.Error( - message = resourceProvider.getString( - R.string.stats_error_unknown - ), - onRetry = ::loadData - ) + ) } } - fun onRetry() { - loadData() + fun showLoading() { + _uiState.value = YearInReviewCardUiState.Loading } fun getDetailData(): List { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 13fb1e578791..4477c0292a6b 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1512,7 +1512,8 @@ All-time stats Most popular day Day - %1$s%% of views + %1$s%% of views + Most popular time 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 138505273237..7591f533e4b7 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 @@ -23,6 +23,7 @@ class InsightsCardsConfigurationTest { assertThat(hiddenCards).containsExactlyInAnyOrder( InsightsCardType.ALL_TIME_STATS, InsightsCardType.MOST_POPULAR_DAY, + InsightsCardType.MOST_POPULAR_TIME, InsightsCardType.YEAR_IN_REVIEW ) } 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 d9b309f5c181..7508b02f36e7 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 @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats +import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher @@ -10,13 +11,20 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsInsightsData +import org.wordpress.android.ui.newstats.datasource.StatsSummaryData import org.wordpress.android.ui.newstats.repository.InsightsCardsConfigurationRepository +import org.wordpress.android.ui.newstats.repository.InsightsResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase +import org.wordpress.android.ui.newstats.repository.StatsInsightsUseCase import org.wordpress.android.util.NetworkUtilsWrapper @ExperimentalCoroutinesApi @@ -35,6 +43,14 @@ class InsightsViewModelTest : private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + @Mock + private lateinit var statsSummaryUseCase: + StatsSummaryUseCase + + @Mock + private lateinit var statsInsightsUseCase: + StatsInsightsUseCase + private lateinit var viewModel: InsightsViewModel private val testSite = SiteModel().apply { @@ -72,7 +88,9 @@ class InsightsViewModelTest : viewModel = InsightsViewModel( selectedSiteRepository, cardConfigurationRepository, - networkUtilsWrapper + networkUtilsWrapper, + statsSummaryUseCase, + statsInsightsUseCase ) } @@ -218,7 +236,9 @@ class InsightsViewModelTest : viewModel = InsightsViewModel( selectedSiteRepository, cardConfigurationRepository, - networkUtilsWrapper + networkUtilsWrapper, + statsSummaryUseCase, + statsInsightsUseCase ) advanceUntilIdle() @@ -331,7 +351,9 @@ class InsightsViewModelTest : viewModel = InsightsViewModel( selectedSiteRepository, cardConfigurationRepository, - networkUtilsWrapper + networkUtilsWrapper, + statsSummaryUseCase, + statsInsightsUseCase ) assertThat(viewModel.cardsToLoad.value) @@ -408,8 +430,239 @@ class InsightsViewModelTest : ).isTrue() } + // region Data fetching tests + + @Test + fun `when loadDataIfNeeded called, then both use cases are invoked`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsInsightsUseCase) + .invoke(eq(TEST_SITE_ID), eq(false)) + } + + @Test + fun `when loadDataIfNeeded called twice, then use cases invoked only once`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase, + org.mockito.Mockito.times(1)) + .invoke(any(), any()) + verify(statsInsightsUseCase, + org.mockito.Mockito.times(1)) + .invoke(any(), any()) + } + + @Test + fun `when fetchData called, then results are emitted`() = + test { + val testSummary = createTestSummaryData() + val testInsights = createTestInsightsData() + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success(testSummary) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success(testInsights) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.summaryResult.test { + viewModel.fetchData() + advanceUntilIdle() + val result = awaitItem() + assertThat(result).isInstanceOf( + StatsSummaryResult + .Success::class.java + ) + assertThat( + (result as StatsSummaryResult.Success) + .data.views + ).isEqualTo(TEST_VIEWS) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when refreshData called, then forceRefresh is true`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + verify(statsSummaryUseCase) + .invoke(eq(TEST_SITE_ID), eq(true)) + verify(statsInsightsUseCase) + .invoke(eq(TEST_SITE_ID), eq(true)) + } + + @Test + fun `when refreshData called, then isDataRefreshing resets to false`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + assertThat(viewModel.isDataRefreshing.value) + .isFalse() + } + + @Suppress("TooGenericExceptionThrown") + @Test + fun `when summary use case throws, then error result is emitted`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenAnswer { + throw RuntimeException("Test error") + } + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.summaryResult.test { + viewModel.fetchData() + advanceUntilIdle() + val result = awaitItem() + assertThat(result).isInstanceOf( + StatsSummaryResult + .Error::class.java + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `when no site selected, then fetchData is no-op`() = + test { + initViewModel() + advanceUntilIdle() + + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + viewModel.fetchData() + advanceUntilIdle() + + verify(statsSummaryUseCase, never()) + .invoke(any(), any()) + } + + // endregion + companion object { private const val TEST_SITE_ID = 123L private const val OTHER_SITE_ID = 456L + private const val TEST_VIEWS = 6782856L + + private fun createTestSummaryData() = + StatsSummaryData( + views = TEST_VIEWS, + visitors = 154791L, + posts = 42L, + comments = 85L, + viewsBestDay = "2022-02-22", + viewsBestDayTotal = 4600L + ) + + private fun createTestInsightsData() = + StatsInsightsData( + highestHour = 14, + highestHourPercent = 15.5, + highestDayOfWeek = 3, + highestDayPercent = 25.0, + years = emptyList() + ) } } 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 index d3f4a54c86af..b8b683342ece 100644 --- 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 @@ -8,143 +8,68 @@ 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 + resourceProvider ) - 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 + fun `initial state is Loading`() { + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loading::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() + fun `when handleResult with success, then loaded state`() { + viewModel.handleResult( + StatsSummaryResult.Success(createTestData()) + ) - 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) - } + 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( + fun `when handleResult with error, then error state`() { + viewModel.handleResult( StatsSummaryResult.Error("Network error") ) - viewModel = AllTimeStatsViewModel( - selectedSiteRepository, - resourceProvider, - statsSummaryUseCase - ) - viewModel.loadData() - advanceUntilIdle() - val state = viewModel.uiState.value assertThat(state).isInstanceOf( AllTimeStatsCardUiState.Error::class.java @@ -155,245 +80,54 @@ class AllTimeStatsViewModelTest : BaseUnitTest() { ).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 + fun `when showLoading called, then loading state`() { + viewModel.handleResult( + StatsSummaryResult.Success(createTestData()) + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java ) - viewModel.loadData() - advanceUntilIdle() - viewModel.refresh() - advanceUntilIdle() + viewModel.showLoading() - 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() - ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loading::class.java ) - 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") + fun `when handleResult after error, then loaded state`() { + viewModel.handleResult( + StatsSummaryResult.Error("error") + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Error::class.java ) - viewModel.refresh() - advanceUntilIdle() - - assertThat(viewModel.uiState.value) - .isInstanceOf( - AllTimeStatsCardUiState - .Error::class.java - ) - whenever( - statsSummaryUseCase(TEST_SITE_ID) - ).thenReturn( - StatsSummaryResult.Success( - createTestData() - ) + viewModel.handleResult( + StatsSummaryResult.Success(createTestData()) + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java ) - 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, 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 index b97491fe0e3c..82038fee50a7 100644 --- 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 @@ -6,143 +6,72 @@ 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 + resourceProvider ) - 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( + fun `initial state is Loading`() { + assertThat(viewModel.uiState.value) + .isInstanceOf( MostPopularDayCardUiState - .Loaded::class.java + .Loading::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 + fun `when handleResult with success, then loaded state has correct day`() { + viewModel.handleResult( + StatsSummaryResult.Success( + data = createTestData() ) + ) + + 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 fetch fails, then error state`() = test { - whenever( - statsSummaryUseCase(TEST_SITE_ID) - ).thenReturn( + fun `when handleResult with error, then error state`() { + viewModel.handleResult( StatsSummaryResult.Error("Network error") ) - viewModel = MostPopularDayViewModel( - selectedSiteRepository, - resourceProvider, - statsSummaryUseCase - ) - viewModel.loadData() - advanceUntilIdle() - assertThat(viewModel.uiState.value) .isInstanceOf( MostPopularDayCardUiState @@ -151,102 +80,18 @@ class MostPopularDayViewModelTest : BaseUnitTest() { } @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") - } + fun `when showLoading called, then loading state`() { + viewModel.handleResult( + StatsSummaryResult.Success(createTestData()) + ) + viewModel.showLoading() - viewModel = MostPopularDayViewModel( - selectedSiteRepository, - resourceProvider, - statsSummaryUseCase + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Loading::class.java ) - 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`() { @@ -304,16 +149,11 @@ class MostPopularDayViewModelTest : BaseUnitTest() { } 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, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModelTest.kt new file mode 100644 index 000000000000..2e0e806a22cc --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopulartime/MostPopularTimeViewModelTest.kt @@ -0,0 +1,354 @@ +package org.wordpress.android.ui.newstats.mostpopulartime + +import android.content.Context +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.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.datasource.StatsInsightsData +import org.wordpress.android.ui.newstats.repository.InsightsResult +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class MostPopularTimeViewModelTest : BaseUnitTest() { + @Mock + private lateinit var resourceProvider: + ResourceProvider + + @Mock + private lateinit var context: Context + + private lateinit var viewModel: + MostPopularTimeViewModel + + @Before + fun setUp() { + lenient().`when`( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(FAILED_TO_LOAD_ERROR) + viewModel = MostPopularTimeViewModel( + context, resourceProvider + ) + } + + @Test + fun `initial state is Loading`() { + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularTimeCardUiState + .Loading::class.java + ) + } + + @Test + fun `when handleResult with error, then error state`() { + viewModel.handleResult( + InsightsResult.Error("Network error") + ) + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularTimeCardUiState + .Error::class.java + ) + assertThat( + (viewModel.uiState.value + as MostPopularTimeCardUiState.Error) + .message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when showLoading called, then loading state`() { + viewModel.handleResult( + InsightsResult.Success( + createTestInsightsData() + ) + ) + viewModel.showLoading() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularTimeCardUiState + .Loading::class.java + ) + } + + @Test + fun `when handleResult after error, then loaded state`() { + viewModel.handleResult( + InsightsResult.Error("error") + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularTimeCardUiState + .Error::class.java + ) + + viewModel.handleResult( + InsightsResult.Success( + createTestInsightsData() + ) + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularTimeCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when mapToUiState with zero percents, then NoData`() { + val data = StatsInsightsData( + highestHour = 0, + highestHourPercent = 0.0, + highestDayOfWeek = 0, + highestDayPercent = 0.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + assertThat(state).isInstanceOf( + MostPopularTimeCardUiState + .NoData::class.java + ) + } + + @Test + fun `when mapToUiState with valid data, then Loaded state`() { + val data = StatsInsightsData( + highestHour = TEST_HIGHEST_HOUR, + highestHourPercent = TEST_HOUR_PERCENT, + highestDayOfWeek = TEST_DAY_OF_WEEK, + highestDayPercent = TEST_DAY_PERCENT, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestDayPercent) + .isEqualTo("25") + assertThat(state.bestHourPercent) + .isEqualTo("16") + } + + @Test + fun `when mapToUiState with day 0, then Monday`() { + val data = StatsInsightsData( + highestHour = 10, + highestHourPercent = 10.0, + highestDayOfWeek = 0, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestDay).isEqualTo("Monday") + } + + @Test + fun `when mapToUiState with day 5, then Saturday`() { + val data = StatsInsightsData( + highestHour = 10, + highestHourPercent = 10.0, + highestDayOfWeek = 5, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestDay).isEqualTo("Saturday") + } + + @Test + fun `when mapToUiState with day 6, then Sunday`() { + val data = StatsInsightsData( + highestHour = 10, + highestHourPercent = 10.0, + highestDayOfWeek = 6, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestDay).isEqualTo("Sunday") + } + + @Test + fun `when mapToUiState with zero day percent, then NoData`() { + val data = StatsInsightsData( + highestHour = 14, + highestHourPercent = 10.0, + highestDayOfWeek = 3, + highestDayPercent = 0.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + assertThat(state).isInstanceOf( + MostPopularTimeCardUiState + .NoData::class.java + ) + } + + @Test + fun `when mapToUiState with zero hour percent, then NoData`() { + val data = StatsInsightsData( + highestHour = 14, + highestHourPercent = 0.0, + highestDayOfWeek = 3, + highestDayPercent = 10.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + assertThat(state).isInstanceOf( + MostPopularTimeCardUiState + .NoData::class.java + ) + } + + @Test + fun `when mapToUiState with invalid day, then empty day name`() { + val data = StatsInsightsData( + highestHour = 10, + highestHourPercent = 10.0, + highestDayOfWeek = 7, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestDay).isEmpty() + } + + @Test + fun `when mapToUiState with invalid hour, then empty hour`() { + val data = StatsInsightsData( + highestHour = 25, + highestHourPercent = 10.0, + highestDayOfWeek = 3, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestHour).isEmpty() + } + + @Test + fun `when mapToUiState with fractional percent, then rounded`() { + val data = StatsInsightsData( + highestHour = 14, + highestHourPercent = 11.18, + highestDayOfWeek = 3, + highestDayPercent = 22.66, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, USE_24H) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestDayPercent) + .isEqualTo("23") + assertThat(state.bestHourPercent) + .isEqualTo("11") + } + + @Test + fun `when 24h format, then hour shows 24h style`() { + val data = StatsInsightsData( + highestHour = 16, + highestHourPercent = 10.0, + highestDayOfWeek = 3, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, use24HourFormat = true) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestHour).isEqualTo("16:00") + } + + @Test + fun `when 12h format, then hour shows AM PM style`() { + val data = StatsInsightsData( + highestHour = 16, + highestHourPercent = 10.0, + highestDayOfWeek = 3, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState( + data, use24HourFormat = false + ) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestHour) + .contains("4:00") + .containsIgnoringCase("pm") + } + + @Test + fun `when 24h format with morning hour, then padded`() { + val data = StatsInsightsData( + highestHour = 9, + highestHourPercent = 10.0, + highestDayOfWeek = 3, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState(data, use24HourFormat = true) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestHour).isEqualTo("09:00") + } + + @Test + fun `when 12h format with midnight, then 12 AM`() { + val data = StatsInsightsData( + highestHour = 0, + highestHourPercent = 10.0, + highestDayOfWeek = 3, + highestDayPercent = 20.0, + years = emptyList() + ) + val state = MostPopularTimeViewModel + .mapToUiState( + data, use24HourFormat = false + ) + as MostPopularTimeCardUiState.Loaded + assertThat(state.bestHour) + .contains("12:00") + .containsIgnoringCase("am") + } + + companion object { + private const val TEST_HIGHEST_HOUR = 16 + private const val TEST_HOUR_PERCENT = 15.5 + private const val TEST_DAY_OF_WEEK = 3 + private const val TEST_DAY_PERCENT = 25.0 + private const val FAILED_TO_LOAD_ERROR = + "Failed to load stats" + private const val USE_24H = true + + private fun createTestInsightsData() = + StatsInsightsData( + highestHour = TEST_HIGHEST_HOUR, + highestHourPercent = TEST_HOUR_PERCENT, + highestDayOfWeek = TEST_DAY_OF_WEEK, + highestDayPercent = TEST_DAY_PERCENT, + years = emptyList() + ) + } +} 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 db3a11e1bb8c..edbcd9524272 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 @@ -90,7 +90,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { .containsExactly( InsightsCardType.YEAR_IN_REVIEW, InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY + InsightsCardType.MOST_POPULAR_DAY, + InsightsCardType.MOST_POPULAR_TIME ) verify(appPrefsWrapper) .setStatsInsightsCardsConfigurationJson( @@ -106,7 +107,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "visibleCards": [ "YEAR_IN_REVIEW", "ALL_TIME_STATS", - "MOST_POPULAR_DAY" + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME" ] } """.trimIndent() @@ -124,7 +126,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { .containsExactly( InsightsCardType.YEAR_IN_REVIEW, InsightsCardType.ALL_TIME_STATS, - InsightsCardType.MOST_POPULAR_DAY + InsightsCardType.MOST_POPULAR_DAY, + InsightsCardType.MOST_POPULAR_TIME ) verify( appPrefsWrapper, @@ -161,7 +164,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "hiddenCards": [ "YEAR_IN_REVIEW", "ALL_TIME_STATS", - "MOST_POPULAR_DAY" + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME" ] } """.trimIndent() @@ -227,7 +231,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "hiddenCards": [ "YEAR_IN_REVIEW", "ALL_TIME_STATS", - "MOST_POPULAR_DAY" + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME" ] } """.trimIndent() @@ -262,7 +267,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "hiddenCards": [ "YEAR_IN_REVIEW", "ALL_TIME_STATS", - "MOST_POPULAR_DAY" + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME" ] } """.trimIndent() @@ -370,7 +376,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.moveCardDown( TEST_SITE_ID, - InsightsCardType.MOST_POPULAR_DAY + InsightsCardType.MOST_POPULAR_TIME ) verify( @@ -416,7 +422,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.moveCardToBottom( TEST_SITE_ID, - InsightsCardType.MOST_POPULAR_DAY + InsightsCardType.MOST_POPULAR_TIME ) verify( @@ -434,7 +440,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "visibleCards": [ "YEAR_IN_REVIEW", "ALL_TIME_STATS", - "MOST_POPULAR_DAY" + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME" ] } """.trimIndent() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt new file mode 100644 index 000000000000..22b41d2fce86 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt @@ -0,0 +1,233 @@ +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.StatsInsightsData +import org.wordpress.android.ui.newstats.datasource.YearInsightsData + +@ExperimentalCoroutinesApi +class StatsInsightsUseCaseTest : BaseUnitTest() { + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var accountStore: AccountStore + + private lateinit var useCase: StatsInsightsUseCase + + @Before + fun setUp() { + whenever(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + useCase = StatsInsightsUseCase( + statsRepository, + accountStore + ) + } + + @Test + fun `when called, then returns cached on second call`() = + test { + whenever( + statsRepository.fetchInsights( + TEST_SITE_ID + ) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + val first = useCase(TEST_SITE_ID) + val second = useCase(TEST_SITE_ID) + + assertThat(first).isInstanceOf( + InsightsResult.Success::class.java + ) + assertThat(second).isInstanceOf( + InsightsResult.Success::class.java + ) + verify(statsRepository, times(1)) + .fetchInsights(TEST_SITE_ID) + } + + @Test + fun `when called with forceRefresh, then fetches again`() = + test { + whenever( + statsRepository.fetchInsights( + TEST_SITE_ID + ) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + useCase(TEST_SITE_ID) + useCase( + TEST_SITE_ID, + forceRefresh = true + ) + + verify(statsRepository, times(2)) + .fetchInsights(TEST_SITE_ID) + } + + @Test + fun `when called without token, then returns error`() = + test { + whenever(accountStore.accessToken) + .thenReturn(null) + useCase = StatsInsightsUseCase( + statsRepository, + accountStore + ) + + val result = useCase(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + InsightsResult.Error::class.java + ) + verify(statsRepository, never()) + .fetchInsights(any()) + } + + @Test + fun `when called with empty token, then returns error`() = + test { + whenever(accountStore.accessToken) + .thenReturn("") + useCase = StatsInsightsUseCase( + statsRepository, + accountStore + ) + + val result = useCase(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + InsightsResult.Error::class.java + ) + verify(statsRepository, never()) + .fetchInsights(any()) + } + + @Test + fun `when errors, then cache is not populated`() = + test { + whenever( + statsRepository.fetchInsights( + TEST_SITE_ID + ) + ).thenReturn( + InsightsResult.Error("Network error") + ) + + val first = useCase(TEST_SITE_ID) + assertThat(first).isInstanceOf( + InsightsResult.Error::class.java + ) + + whenever( + statsRepository.fetchInsights( + TEST_SITE_ID + ) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + val second = useCase(TEST_SITE_ID) + assertThat(second).isInstanceOf( + InsightsResult.Success::class.java + ) + verify(statsRepository, times(2)) + .fetchInsights(TEST_SITE_ID) + } + + @Test + fun `when called for different site, then fetches again`() = + test { + whenever( + statsRepository.fetchInsights(any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + useCase(TEST_SITE_ID) + useCase(OTHER_SITE_ID) + + verify(statsRepository, times(1)) + .fetchInsights(TEST_SITE_ID) + verify(statsRepository, times(1)) + .fetchInsights(OTHER_SITE_ID) + } + + @Test + fun `when success, then data is returned correctly`() = + test { + val testData = createTestInsightsData() + whenever( + statsRepository.fetchInsights( + TEST_SITE_ID + ) + ).thenReturn( + InsightsResult.Success(testData) + ) + + val result = useCase(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + InsightsResult.Success::class.java + ) + val success = + result as InsightsResult.Success + assertThat(success.data.highestHour) + .isEqualTo(TEST_HIGHEST_HOUR) + assertThat(success.data.highestDayOfWeek) + .isEqualTo(TEST_DAY_OF_WEEK) + assertThat(success.data.years).hasSize(1) + } + + private fun createTestInsightsData() = + StatsInsightsData( + highestHour = TEST_HIGHEST_HOUR, + highestHourPercent = 15.5, + highestDayOfWeek = TEST_DAY_OF_WEEK, + highestDayPercent = 25.0, + years = listOf( + YearInsightsData( + year = "2025", + totalPosts = 42L, + totalWords = 15000L, + avgWords = 357.1, + totalLikes = 230L, + avgLikes = 5.5, + totalComments = 85L, + avgComments = 2.0 + ) + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val OTHER_SITE_ID = 456L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + private const val TEST_HIGHEST_HOUR = 16 + private const val TEST_DAY_OF_WEEK = 3 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt index a041dab18344..b842daaf78f6 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt @@ -51,15 +51,15 @@ class StatsRepositoryInsightsTest : BaseUnitTest() { InsightsResult.Success::class.java ) val success = result as InsightsResult.Success - assertThat(success.years).hasSize(2) - assertThat(success.years[0].year).isEqualTo("2025") - assertThat(success.years[0].totalPosts) + assertThat(success.data.years).hasSize(2) + assertThat(success.data.years[0].year).isEqualTo("2025") + assertThat(success.data.years[0].totalPosts) .isEqualTo(TEST_TOTAL_POSTS) - assertThat(success.years[0].totalWords) + assertThat(success.data.years[0].totalWords) .isEqualTo(TEST_TOTAL_WORDS) - assertThat(success.years[0].totalLikes) + assertThat(success.data.years[0].totalLikes) .isEqualTo(TEST_TOTAL_LIKES) - assertThat(success.years[0].totalComments) + assertThat(success.data.years[0].totalComments) .isEqualTo(TEST_TOTAL_COMMENTS) } @@ -83,7 +83,13 @@ class StatsRepositoryInsightsTest : BaseUnitTest() { @Test fun `given empty years list, when fetchInsights, then success with empty list`() = test { - val emptyData = StatsInsightsData(years = emptyList()) + val emptyData = StatsInsightsData( + highestHour = 0, + highestHourPercent = 0.0, + highestDayOfWeek = 0, + highestDayPercent = 0.0, + years = emptyList() + ) whenever(statsDataSource.fetchStatsInsights(any())) .thenReturn( StatsInsightsDataResult.Success(emptyData) @@ -95,7 +101,7 @@ class StatsRepositoryInsightsTest : BaseUnitTest() { InsightsResult.Success::class.java ) val success = result as InsightsResult.Success - assertThat(success.years).isEmpty() + assertThat(success.data.years).isEmpty() } @Test @@ -128,12 +134,16 @@ class StatsRepositoryInsightsTest : BaseUnitTest() { val result = repository.fetchInsights(TEST_SITE_ID) val success = result as InsightsResult.Success - assertThat(success.years[0].year).isEqualTo("2025") - assertThat(success.years[1].year).isEqualTo("2024") - assertThat(success.years[1].totalPosts).isEqualTo(38L) + assertThat(success.data.years[0].year).isEqualTo("2025") + assertThat(success.data.years[1].year).isEqualTo("2024") + assertThat(success.data.years[1].totalPosts).isEqualTo(38L) } private fun createTestInsightsData() = StatsInsightsData( + highestHour = 14, + highestHourPercent = 15.5, + highestDayOfWeek = 3, + highestDayPercent = 25.0, years = listOf( YearInsightsData( year = "2025", diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt index aede1bf79f2c..13740d4f4cb6 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt @@ -5,499 +5,208 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.Mock -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.times -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.AccountStore -import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsInsightsData import org.wordpress.android.ui.newstats.datasource.YearInsightsData import org.wordpress.android.ui.newstats.repository.InsightsResult -import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.viewmodel.ResourceProvider import java.time.Year @ExperimentalCoroutinesApi class YearInReviewViewModelTest : BaseUnitTest() { @Mock - private lateinit var selectedSiteRepository: SelectedSiteRepository - - @Mock - private lateinit var accountStore: AccountStore - - @Mock - private lateinit var statsRepository: StatsRepository - - @Mock - private lateinit var resourceProvider: ResourceProvider + private lateinit var resourceProvider: + ResourceProvider private lateinit var viewModel: YearInReviewViewModel - private val testSite = SiteModel().apply { - id = 1 - siteId = TEST_SITE_ID - name = "Test Site" - } - @Before fun setUp() { - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(testSite) - whenever(accountStore.accessToken) - .thenReturn(TEST_ACCESS_TOKEN) - whenever( - resourceProvider.getString(R.string.stats_error_no_site) - ).thenReturn(NO_SITE_SELECTED_ERROR) whenever( - resourceProvider.getString(R.string.stats_error_api) + resourceProvider.getString( + R.string.stats_error_api + ) ).thenReturn(FAILED_TO_LOAD_ERROR) - whenever( - resourceProvider.getString(R.string.stats_error_unknown) - ).thenReturn(UNKNOWN_ERROR) - } - - private fun initViewModel() { viewModel = YearInReviewViewModel( - selectedSiteRepository, - accountStore, - statsRepository, resourceProvider ) - viewModel.loadData() } @Test - fun `when no site selected, then error state is emitted`() = - test { - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(null) - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - YearInReviewCardUiState.Error::class.java - ) - assertThat( - (state as YearInReviewCardUiState.Error).message - ).isEqualTo(NO_SITE_SELECTED_ERROR) - } - - @Test - fun `when data loads successfully, then loaded state is emitted`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - YearInReviewCardUiState.Loaded::class.java - ) - with(state as YearInReviewCardUiState.Loaded) { - assertThat(years).hasSize(3) - assertThat(years[0].year) - .isEqualTo(CURRENT_YEAR) - assertThat(years[1].year).isEqualTo("2025") - assertThat(years[1].totalPosts) - .isEqualTo(TEST_TOTAL_POSTS) - assertThat(years[1].totalWords) - .isEqualTo(TEST_TOTAL_WORDS) - assertThat(years[1].totalLikes) - .isEqualTo(TEST_TOTAL_LIKES) - assertThat(years[1].totalComments) - .isEqualTo(TEST_TOTAL_COMMENTS) - } - } - - @Test - fun `when fetch fails with error, then error state is emitted`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn(InsightsResult.Error("Network error")) - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - YearInReviewCardUiState.Error::class.java - ) - assertThat( - (state as YearInReviewCardUiState.Error).message - ).isEqualTo(FAILED_TO_LOAD_ERROR) - } - - @Test - fun `when exception is thrown during fetch, then error state is emitted`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenThrow(RuntimeException("Test exception")) - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - YearInReviewCardUiState.Error::class.java - ) - assertThat( - (state as YearInReviewCardUiState.Error).message - ).isEqualTo(UNKNOWN_ERROR) - } - - @Test - fun `when exception with null message, then unknown error message`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenThrow(RuntimeException()) - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - YearInReviewCardUiState.Error::class.java - ) - assertThat( - (state as YearInReviewCardUiState.Error).message - ).isEqualTo(UNKNOWN_ERROR) - } - - @Test - fun `when onRetry is called, then data is reloaded`() = test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) + fun `initial state is Loading`() { + assertThat(viewModel.uiState.value) + .isInstanceOf( + YearInReviewCardUiState + .Loading::class.java ) - - initViewModel() - advanceUntilIdle() - - viewModel.onRetry() - advanceUntilIdle() - - verify(statsRepository, times(2)) - .fetchInsights(eq(TEST_SITE_ID)) } @Test - fun `when refresh is called, then isRefreshing becomes true then false`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - - initViewModel() - advanceUntilIdle() - - assertThat(viewModel.isRefreshing.value).isFalse() - - viewModel.refresh() - advanceUntilIdle() + fun `when handleResult with success, then loaded state is emitted`() { + viewModel.handleResult( + createSuccessResult(), + ) - assertThat(viewModel.isRefreshing.value).isFalse() + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Loaded::class.java + ) + with( + state as YearInReviewCardUiState.Loaded + ) { + assertThat(years).hasSize(3) + assertThat(years[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(years[1].year) + .isEqualTo("2025") + assertThat(years[1].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) + assertThat(years[1].totalWords) + .isEqualTo(TEST_TOTAL_WORDS) + assertThat(years[1].totalLikes) + .isEqualTo(TEST_TOTAL_LIKES) + assertThat(years[1].totalComments) + .isEqualTo(TEST_TOTAL_COMMENTS) } - - @Test - fun `when refresh is called, then data is fetched`() = test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - - initViewModel() - advanceUntilIdle() - - viewModel.refresh() - advanceUntilIdle() - - verify(statsRepository, times(2)) - .fetchInsights(eq(TEST_SITE_ID)) } @Test - fun `when access token is null, then error state is emitted`() = - test { - whenever(accountStore.accessToken).thenReturn(null) - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - YearInReviewCardUiState.Error::class.java - ) - assertThat( - (state as YearInReviewCardUiState.Error).message - ).isEqualTo(FAILED_TO_LOAD_ERROR) - } - - @Test - fun `when access token is empty, then error state is emitted`() = - test { - whenever(accountStore.accessToken).thenReturn("") - - initViewModel() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - YearInReviewCardUiState.Error::class.java - ) - assertThat( - (state as YearInReviewCardUiState.Error).message - ).isEqualTo(FAILED_TO_LOAD_ERROR) - } - - @Test - fun `when loadData is called, then repository is initialized with access token`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - - initViewModel() - advanceUntilIdle() - - verify(statsRepository).init(eq(TEST_ACCESS_TOKEN)) - } - - @Test - fun `when loadDataIfNeeded is called multiple times, then data is only loaded once`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - - viewModel = YearInReviewViewModel( - selectedSiteRepository, - accountStore, - statsRepository, - resourceProvider - ) - viewModel.loadDataIfNeeded() - advanceUntilIdle() - - viewModel.loadDataIfNeeded() - advanceUntilIdle() - - viewModel.loadDataIfNeeded() - advanceUntilIdle() + fun `when handleResult with error, then error state is emitted`() { + viewModel.handleResult( + InsightsResult.Error("Network error"), + ) - verify(statsRepository, times(1)) - .fetchInsights(eq(TEST_SITE_ID)) - } + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + YearInReviewCardUiState.Error::class.java + ) + assertThat( + (state as YearInReviewCardUiState.Error) + .message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } @Test - fun `when error state retry is clicked, then data is reloaded`() = - test { - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(null) - - initViewModel() - advanceUntilIdle() - - val errorState = - viewModel.uiState.value as YearInReviewCardUiState.Error - - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(testSite) - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - - errorState.onRetry() - advanceUntilIdle() + fun `when showLoading called, then loading state`() { + viewModel.handleResult( + createSuccessResult(), + ) + viewModel.showLoading() - assertThat(viewModel.uiState.value).isInstanceOf( - YearInReviewCardUiState.Loaded::class.java + assertThat(viewModel.uiState.value) + .isInstanceOf( + YearInReviewCardUiState + .Loading::class.java ) - } + } @Test - fun `when data loads with empty years, then current year is added`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success(years = emptyList()) + fun `when data loads with empty years, then current year is added`() { + viewModel.handleResult( + InsightsResult.Success( + data = createTestInsightsData( + emptyList() ) + ), + ) - initViewModel() - advanceUntilIdle() - - val state = - viewModel.uiState.value - as YearInReviewCardUiState.Loaded - assertThat(state.years).hasSize(1) - assertThat(state.years[0].year) - .isEqualTo(CURRENT_YEAR) - assertThat(state.years[0].totalPosts) - .isEqualTo(0L) - } + val state = viewModel.uiState.value + as YearInReviewCardUiState.Loaded + assertThat(state.years).hasSize(1) + assertThat(state.years[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(state.years[0].totalPosts) + .isEqualTo(0L) + } @Test - fun `when current year exists in data, then no duplicate is added`() = - test { - val yearsWithCurrent = listOf( - YearInsightsData( - year = CURRENT_YEAR, - totalPosts = TEST_TOTAL_POSTS, - totalWords = TEST_TOTAL_WORDS, - avgWords = TEST_AVG_WORDS, - totalLikes = TEST_TOTAL_LIKES, - avgLikes = TEST_AVG_LIKES, - totalComments = TEST_TOTAL_COMMENTS, - avgComments = TEST_AVG_COMMENTS - ) + fun `when current year exists in data, then no duplicate is added`() { + val yearsWithCurrent = listOf( + YearInsightsData( + year = CURRENT_YEAR, + totalPosts = TEST_TOTAL_POSTS, + totalWords = TEST_TOTAL_WORDS, + avgWords = TEST_AVG_WORDS, + totalLikes = TEST_TOTAL_LIKES, + avgLikes = TEST_AVG_LIKES, + totalComments = TEST_TOTAL_COMMENTS, + avgComments = TEST_AVG_COMMENTS ) - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = yearsWithCurrent - ) - ) - - initViewModel() - advanceUntilIdle() - - val state = - viewModel.uiState.value - as YearInReviewCardUiState.Loaded - assertThat(state.years).hasSize(1) - assertThat(state.years[0].year) - .isEqualTo(CURRENT_YEAR) - assertThat(state.years[0].totalPosts) - .isEqualTo(TEST_TOTAL_POSTS) - } - - @Test - fun `when state is loaded, then getDetailData returns years`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) + ) + viewModel.handleResult( + InsightsResult.Success( + data = createTestInsightsData( + yearsWithCurrent ) + ), + ) - initViewModel() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - assertThat(detailData).hasSize(3) - assertThat(detailData[0].year) - .isEqualTo(CURRENT_YEAR) - assertThat(detailData[1].year).isEqualTo("2025") - assertThat(detailData[1].totalPosts) - .isEqualTo(TEST_TOTAL_POSTS) - assertThat(detailData[2].year).isEqualTo("2024") - } + val state = viewModel.uiState.value + as YearInReviewCardUiState.Loaded + assertThat(state.years).hasSize(1) + assertThat(state.years[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(state.years[0].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) + } @Test - fun `when state is loading, then getDetailData returns empty list`() = - test { - viewModel = YearInReviewViewModel( - selectedSiteRepository, - accountStore, - statsRepository, - resourceProvider - ) + fun `when state is loaded, then getDetailData returns years`() { + viewModel.handleResult( + createSuccessResult(), + ) - val detailData = viewModel.getDetailData() - assertThat(detailData).isEmpty() - } + val detailData = viewModel.getDetailData() + assertThat(detailData).hasSize(3) + assertThat(detailData[0].year) + .isEqualTo(CURRENT_YEAR) + assertThat(detailData[1].year) + .isEqualTo("2025") + assertThat(detailData[1].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) + assertThat(detailData[2].year) + .isEqualTo("2024") + } @Test - fun `when state is error, then getDetailData returns empty list`() = - test { - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(null) - - initViewModel() - advanceUntilIdle() - - assertThat(viewModel.uiState.value).isInstanceOf( - YearInReviewCardUiState.Error::class.java - ) - val detailData = viewModel.getDetailData() - assertThat(detailData).isEmpty() - } + fun `when state is loading, then getDetailData returns empty list`() { + val detailData = viewModel.getDetailData() + assertThat(detailData).isEmpty() + } @Test - fun `when refresh fails after success, then loadDataIfNeeded reloads`() = - test { - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - - viewModel = YearInReviewViewModel( - selectedSiteRepository, - accountStore, - statsRepository, - resourceProvider - ) - viewModel.loadDataIfNeeded() - advanceUntilIdle() + fun `when state is error, then getDetailData returns empty list`() { + viewModel.handleResult( + InsightsResult.Error("error"), + ) - assertThat(viewModel.uiState.value).isInstanceOf( - YearInReviewCardUiState.Loaded::class.java + assertThat(viewModel.uiState.value) + .isInstanceOf( + YearInReviewCardUiState + .Error::class.java ) + val detailData = viewModel.getDetailData() + assertThat(detailData).isEmpty() + } - whenever(statsRepository.fetchInsights(any())) - .thenReturn(InsightsResult.Error("Network error")) - viewModel.refresh() - advanceUntilIdle() - - assertThat(viewModel.uiState.value).isInstanceOf( - YearInReviewCardUiState.Error::class.java + private fun createSuccessResult() = + InsightsResult.Success( + data = createTestInsightsData( + createTestYears() ) + ) - whenever(statsRepository.fetchInsights(any())) - .thenReturn( - InsightsResult.Success( - years = createTestYears() - ) - ) - viewModel.loadDataIfNeeded() - advanceUntilIdle() - - assertThat(viewModel.uiState.value).isInstanceOf( - YearInReviewCardUiState.Loaded::class.java - ) - verify(statsRepository, times(3)) - .fetchInsights(eq(TEST_SITE_ID)) - } + private fun createTestInsightsData( + years: List + ) = StatsInsightsData( + highestHour = 14, + highestHourPercent = 15.5, + highestDayOfWeek = 3, + highestDayPercent = 25.0, + years = years + ) private fun createTestYears() = listOf( YearInsightsData( @@ -523,8 +232,6 @@ class YearInReviewViewModelTest : BaseUnitTest() { ) companion object { - private const val TEST_SITE_ID = 123L - private const val TEST_ACCESS_TOKEN = "test_access_token" private const val TEST_TOTAL_POSTS = 42L private const val TEST_TOTAL_WORDS = 15000L private const val TEST_AVG_WORDS = 357.1 @@ -532,11 +239,8 @@ class YearInReviewViewModelTest : BaseUnitTest() { private const val TEST_AVG_LIKES = 5.5 private const val TEST_TOTAL_COMMENTS = 85L private const val TEST_AVG_COMMENTS = 2.0 - 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 val CURRENT_YEAR = Year.now().toString() } From a2d0dbf1adb159c4105b5f2245d293d30358e50e Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Fri, 13 Mar 2026 16:14:05 +0100 Subject: [PATCH 12/14] Add Tags & Categories insights card to new stats screen (#22687) * Add Most Popular Time Insights card Introduce a new "Most popular time" card in the stats insights tab that shows the best day of week and best hour with their view percentages. The card reuses the insights API endpoint via a new shared StatsInsightsUseCase (following the StatsSummaryUseCase caching pattern), which also refactors YearInReviewViewModel to use the same use case. Co-Authored-By: Claude Opus 4.6 * Clean up MostPopularTimeViewModel: locale-aware hour formatting and remove unnecessary @Volatile Use DateTimeFormatter.ofLocalizedTime instead of hardcoded AM/PM to respect device locale settings. Remove unnecessary @Volatile annotations since all access is on the main thread via viewModelScope. Co-Authored-By: Claude Opus 4.6 * Add tests for MostPopularTimeViewModel and StatsInsightsUseCase Co-Authored-By: Claude Opus 4.6 * Fix day-of-week mapping, NoData condition, and add bounds check - Fix day mapping: WordPress API uses 0=Monday (not Sunday) - Show NoData when either day or hour percent is zero (not both) - Add bounds check for invalid day values (returns empty string) - Update and add tests for new behavior Co-Authored-By: Claude Opus 4.6 * Fix detekt: suppress LongMethod and remove unused import Co-Authored-By: Claude Opus 4.6 * Centralize Insights data fetching in InsightsViewModel Move data fetching from individual card ViewModels to InsightsViewModel as coordinator, ensuring each API endpoint (stats summary and insights) is called only once per load. Card ViewModels now receive results via SharedFlow instead of fetching independently, reducing duplicate network calls from 4 to 2. Co-Authored-By: Claude Opus 4.6 * Fix race condition, consistent onRetry pattern, and remove unused siteId - Set isDataLoading in refreshData() to prevent duplicate fetches - Move onRetry from YearInReviewCardUiState.Error to composable param - Remove unused siteId property, use resolvedSiteId() directly Co-Authored-By: Claude Opus 4.6 * Add formatHour bounds check, remove duplicate string, clean up import - Guard formatHour against invalid hour values (crash prevention) - Remove duplicate stats_insights_percent_of_views string resource - Use import for kotlin.math.round instead of fully qualified call Co-Authored-By: Claude Opus 4.6 * Fix detekt LongMethod: extract fetchSummary and fetchInsights Co-Authored-By: Claude Opus 4.6 * Reduce duplication in MostPopularTimeCard using shared components Replace manual card container, header, error content, and shimmer boxes with StatsCardContainer, StatsCardHeader, StatsCardErrorContent, and ShimmerBox. Extract repeated day/hour section into StatSection. Co-Authored-By: Claude Opus 4.6 * Rename views percent string resource and add NoData preview - Rename stats_insights_most_popular_day_percent to stats_insights_views_percent for neutral naming - Add missing NoData preview to MostPopularTimeCard Co-Authored-By: Claude Opus 4.6 * Trigger PR checks * Use device 24h/12h setting for hour formatting Use android.text.format.DateFormat.is24HourFormat() to respect the device time format preference instead of relying on locale. Co-Authored-By: Claude Opus 4.6 * Fix thread safety, CancellationException handling, and lambda allocation - Add @Volatile to isDataLoaded/isDataLoading flags - Rethrow CancellationException to preserve structured concurrency - Wrap onRetryData lambda with remember to avoid recomposition allocations Co-Authored-By: Claude Opus 4.6 * Add Tags & Categories insights card Add a new top-list card to the Insights tab showing tags and categories with view counts. Includes expandable multi-tag groups, percentage bars, folder/tag icons, and a detail screen via Show All. Updates wordpress-rs to 1230 for the statsTags endpoint. Co-Authored-By: Claude Opus 4.6 * Add expand/collapse for multi-tag groups in detail screen Co-Authored-By: Claude Opus 4.6 * Add tests for Tags & Categories feature ViewModel, repository, and display type unit tests covering success/error states, data mapping, refresh, and edge cases. Co-Authored-By: Claude Opus 4.6 * Simplify Tags & Categories by reusing shared components - Use StatsCardContainer, StatsCardHeader, StatsListHeader, StatsCardEmptyContent, StatsListRowContainer from StatsCardCommon - Extract TagTypeIcon and ExpandedTagsSection into shared TagsAndCategoriesComponents to eliminate duplication between Card and DetailActivity - Add fromTagType() to TagGroupDisplayType to avoid list allocation per tag in ExpandedTagsSection - Add modifier parameter to StatsListRowContainer for clickable rows - Remove duplicated constants (CardCornerRadius, BAR_BACKGROUND_ALPHA, VERTICAL_LINE_ALPHA) Co-Authored-By: Claude Opus 4.6 * Remove unused stubUnknownError from ViewModel test Co-Authored-By: Claude Opus 4.6 * Fix review issues: error recovery, conditional refresh, loading state - Only set isLoaded on success so loadData() retries after errors - Guard pull-to-refresh to skip tags refresh when card is hidden - Move loading state into refresh() so callers don't need showLoading() - Remove showLoading() public method - Add test for loadData() retry after error Co-Authored-By: Claude Opus 4.6 * Address review feedback: deduplicate row composable, remove unused link field, fix thread safety and Intent size - Extract shared TagGroupRow composable into TagsAndCategoriesComponents.kt with optional position parameter, removing duplicate from Card and DetailActivity - Remove unused TagData.link field from data source, impl, and all tests - Replace Intent extras with in-memory static holder in DetailActivity to avoid TransactionTooLargeException risk - Remove unnecessary Parcelable from UI models - Use AtomicBoolean for isLoaded/isLoading flags in ViewModel for thread safety Co-Authored-By: Claude Opus 4.6 * Fix concurrent refresh, process death, isExpandable duplication, and accessibility - Cancel in-flight fetch job on refresh() to prevent stale overwrites - Finish detail activity on process death instead of showing blank screen - Extract TagGroupUiItem.isExpandable computed property to deduplicate logic - Add content descriptions for TagTypeIcon and expand/collapse chevron icons Co-Authored-By: Claude Opus 4.6 * Update configuration tests to include TAGS_AND_CATEGORIES card type Fixes CI test failures caused by the new TAGS_AND_CATEGORIES card not being reflected in test fixtures and assertions. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- WordPress/src/main/AndroidManifest.xml | 5 + .../android/ui/newstats/InsightsCardType.kt | 6 +- .../android/ui/newstats/NewStatsActivity.kt | 73 ++ .../ui/newstats/components/StatsCardCommon.kt | 3 +- .../ui/newstats/datasource/StatsDataSource.kt | 47 ++ .../datasource/StatsDataSourceImpl.kt | 62 ++ .../ui/newstats/repository/StatsRepository.kt | 44 ++ .../TagsAndCategoriesCard.kt | 384 ++++++++++ .../TagsAndCategoriesCardUiState.kt | 75 ++ .../TagsAndCategoriesComponents.kt | 230 ++++++ .../TagsAndCategoriesDetailActivity.kt | 238 ++++++ .../TagsAndCategoriesViewModel.kt | 154 ++++ .../android/ui/postsrs/PostRsListUiState.kt | 1 + WordPress/src/main/res/values/strings.xml | 4 + .../InsightsCardsConfigurationTest.kt | 3 +- ...nsightsCardsConfigurationRepositoryTest.kt | 25 +- .../repository/StatsRepositoryTagsTest.kt | 279 +++++++ .../TagGroupDisplayTypeTest.kt | 130 ++++ .../TagsAndCategoriesViewModelTest.kt | 703 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 20 files changed, 2455 insertions(+), 13 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagGroupDisplayTypeTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index feda12ee32af..b02ae9699196 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -132,6 +132,11 @@ android:theme="@style/WordPress.NoActionBar" android:exported="false" /> + + + TagsAndCategoriesCard( + uiState = + tagsAndCategoriesUiState, + onShowAllClick = { + val items = + tagsAndCategoriesViewModel + .getDetailData() + TagsAndCategoriesDetailActivity + .start( + context, + items + ) + }, + onRemoveCard = { + insightsViewModel + .removeCard( + cardType + ) + }, + onRetry = { + tagsAndCategoriesViewModel + .refresh() + }, + cardPosition = + cardPosition, + onMoveUp = { + insightsViewModel + .moveCardUp( + cardType + ) + }, + onMoveToTop = { + insightsViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + insightsViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + insightsViewModel + .moveCardToBottom( + cardType + ) + } + ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt index 6ea2e6b20ccb..61a444403d1a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt @@ -246,12 +246,13 @@ fun StatsListHeader( @Composable fun StatsListRowContainer( percentage: Float, + modifier: Modifier = Modifier, content: @Composable () -> Unit ) { val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(IntrinsicSize.Min) .clip(RoundedCornerShape(8.dp)) 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 31ad6cd2910d..84867996d56b 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 @@ -227,6 +227,18 @@ interface StatsDataSource { suspend fun fetchStatsSummary( siteId: Long ): StatsSummaryDataResult + + /** + * Fetches tags and categories stats for a specific site. + * + * @param siteId The WordPress.com site ID + * @param max Maximum number of tag groups to return + * @return Result containing the tags data or an error + */ + suspend fun fetchStatsTags( + siteId: Long, + max: Int = 10 + ): StatsTagsDataResult } /** @@ -610,3 +622,38 @@ data class StatsSummaryData( val viewsBestDay: String, val viewsBestDayTotal: Long ) + +/** + * Result wrapper for stats tags fetch operation. + */ +sealed class StatsTagsDataResult { + data class Success( + val data: StatsTagsData + ) : StatsTagsDataResult() + data class Error( + val errorType: StatsErrorType + ) : StatsTagsDataResult() +} + +/** + * Tags and categories data from the API. + */ +data class StatsTagsData( + val tagGroups: List +) + +/** + * A group of tags associated with views. + */ +data class TagGroupData( + val tags: List, + val views: Long +) + +/** + * A single tag or category item. + */ +data class TagData( + val tagType: String, + val name: String +) 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 d16db5d0c8ac..edd56b446651 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 @@ -19,6 +19,7 @@ import uniffi.wp_api.StatsRegionViewsPeriod import uniffi.wp_api.StatsDevicesParams import uniffi.wp_api.StatsDevicesPeriod import uniffi.wp_api.StatsInsightsParams +import uniffi.wp_api.StatsTagsParams import uniffi.wp_api.StatsSearchTermsParams import uniffi.wp_api.StatsSearchTermsPeriod import uniffi.wp_api.StatsTopAuthorsParams @@ -1108,6 +1109,67 @@ class StatsDataSourceImpl @Inject constructor( } } + private fun mapToStatsTagsData( + tagGroups: List + ): StatsTagsData { + return StatsTagsData( + tagGroups = tagGroups.map { group -> + TagGroupData( + tags = group.tags.map { tag -> + TagData( + tagType = tag.tagType, + name = tag.name + ) + }, + views = group.views.toLong() + ) + } + ) + } + + override suspend fun fetchStatsTags( + siteId: Long, + max: Int + ): StatsTagsDataResult { + val params = StatsTagsParams( + max = if (max > 0) max.toUInt() else null, + locale = wpComLanguage + ) + val result = getOrCreateClient() + .request { requestBuilder -> + requestBuilder.statsTags() + .getStatsTags( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + logResultType("fetchStatsTags", result) + + return when (result) { + is WpRequestResult.Success -> { + val tagGroups = + result.response.data.tags + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchStatsTags success " + + "- ${tagGroups.size} " + + "tag groups" + ) + StatsTagsDataResult.Success( + mapToStatsTagsData(tagGroups) + ) + } + else -> logErrorAndReturn( + "fetchStatsTags", + result + ) { + StatsTagsDataResult.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/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 11103514c890..c89fe7b685cf 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 @@ -14,6 +14,8 @@ import org.wordpress.android.ui.newstats.datasource.SearchTermsDataResult import org.wordpress.android.ui.newstats.datasource.StatsDataSource import org.wordpress.android.ui.newstats.datasource.StatsInsightsData import org.wordpress.android.ui.newstats.datasource.StatsInsightsDataResult +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import org.wordpress.android.ui.newstats.datasource.StatsTagsDataResult import org.wordpress.android.ui.newstats.datasource.StatsSummaryDataResult import org.wordpress.android.ui.newstats.datasource.StatsSummaryData import org.wordpress.android.ui.newstats.datasource.StatsDateRange @@ -1373,6 +1375,36 @@ class StatsRepository @Inject constructor( } } } + + suspend fun fetchTags( + siteId: Long, + max: Int = DEFAULT_TAGS_MAX + ): TagsResult = withContext(ioDispatcher) { + val result = statsDataSource.fetchStatsTags( + siteId = siteId, + max = max + ) + when (result) { + is StatsTagsDataResult.Success -> + TagsResult.Success( + data = result.data + ) + is StatsTagsDataResult.Error -> { + appLogWrapper.e( + AppLog.T.STATS, + "Error fetching tags: " + + "${result.errorType}" + ) + TagsResult.Error( + result.errorType.name + ) + } + } + } + + companion object { + private const val DEFAULT_TAGS_MAX = 10 + } } /** @@ -1779,3 +1811,15 @@ sealed class StatsSummaryResult { val message: String ) : StatsSummaryResult() } + +/** + * Result of fetching tags data from the repository. + */ +sealed class TagsResult { + data class Success( + val data: StatsTagsData + ) : TagsResult() + data class Error( + val message: String + ) : TagsResult() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt new file mode 100644 index 000000000000..cd83673d8b97 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt @@ -0,0 +1,384 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.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.ShowAllFooter +import org.wordpress.android.ui.newstats.components.StatsCardContainer +import org.wordpress.android.ui.newstats.components.StatsCardEmptyContent +import org.wordpress.android.ui.newstats.components.StatsCardHeader +import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.util.ShimmerBox + +private val CardPadding = 16.dp +private const val LOADING_SHIMMER_ITEM_COUNT = 5 + +@Composable +@Suppress("LongParameterList") +fun TagsAndCategoriesCard( + uiState: TagsAndCategoriesCardUiState, + onShowAllClick: () -> Unit, + onRetry: () -> Unit, + onRemoveCard: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + when (uiState) { + is TagsAndCategoriesCardUiState.Loading -> + LoadingContent() + is TagsAndCategoriesCardUiState.Loaded -> + LoadedContent( + uiState, + onShowAllClick, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is TagsAndCategoriesCardUiState.Error -> + ErrorContent( + uiState, + onRetry, + onRemoveCard, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + ShimmerBox( + modifier = Modifier + .width(140.dp) + .height(24.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + ShimmerBox( + modifier = Modifier + .width(120.dp) + .height(20.dp) + ) + ShimmerBox( + modifier = Modifier + .width(50.dp) + .height(20.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + repeat(LOADING_SHIMMER_ITEM_COUNT) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + ShimmerBox( + modifier = Modifier + .weight(1f) + .height(20.dp) + ) + Spacer( + modifier = Modifier.width(16.dp) + ) + ShimmerBox( + modifier = Modifier + .width(40.dp) + .height(16.dp) + ) + } + } + } +} + +@Composable +@Suppress("LongParameterList") +private fun LoadedContent( + state: TagsAndCategoriesCardUiState.Loaded, + onShowAllClick: () -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + val expandedGroups = remember { + mutableStateMapOf() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string + .stats_insights_tags_and_categories, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + if (state.items.isEmpty()) { + StatsCardEmptyContent() + } else { + StatsListHeader( + leftHeaderResId = + R.string + .stats_insights_tags_and_categories, + rightHeaderResId = + R.string.stats_views + ) + Spacer(modifier = Modifier.height(8.dp)) + state.items.forEachIndexed { index, item -> + val percentage = + if (state.maxViewsForBar > 0) { + item.views.toFloat() / + state.maxViewsForBar + .toFloat() + } else { + 0f + } + val isExpanded = + expandedGroups[index] == true + + TagGroupRow( + item = item, + percentage = percentage, + isExpandable = item.isExpandable, + isExpanded = isExpanded, + onClick = if (item.isExpandable) { + { + expandedGroups[index] = + !isExpanded + } + } else { + null + } + ) + if (item.isExpandable) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + ExpandedTagsSection( + tags = item.tags + ) + } + } + if (index < state.items.lastIndex) { + Spacer( + modifier = + Modifier.height(4.dp) + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + ShowAllFooter(onClick = onShowAllClick) + } + } +} + +@Composable +@Suppress("LongParameterList") +private fun ErrorContent( + state: TagsAndCategoriesCardUiState.Error, + onRetry: () -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string + .stats_insights_tags_and_categories, + onRemoveCard = 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 TagsAndCategoriesCardLoadingPreview() { + AppThemeM3 { + TagsAndCategoriesCard( + uiState = TagsAndCategoriesCardUiState + .Loading, + onShowAllClick = {}, + onRetry = {}, + onRemoveCard = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TagsAndCategoriesCardLoadedPreview() { + AppThemeM3 { + TagsAndCategoriesCard( + uiState = TagsAndCategoriesCardUiState + .Loaded( + items = listOf( + TagGroupUiItem( + name = "Uncategorized", + tags = listOf( + TagUiItem( + name = + "Uncategorized", + tagType = "category" + ) + ), + views = 83, + displayType = + TagGroupDisplayType + .CATEGORY + ), + TagGroupUiItem( + name = "snaps", + tags = listOf( + TagUiItem( + name = "snaps", + tagType = "tag" + ) + ), + views = 15, + displayType = + TagGroupDisplayType.TAG + ), + TagGroupUiItem( + name = "swiftui / stats" + + " / jetpack / ios", + tags = listOf( + TagUiItem( + name = "swiftui", + tagType = "tag" + ), + TagUiItem( + name = "stats", + tagType = "tag" + ), + TagUiItem( + name = "jetpack", + tagType = "tag" + ), + TagUiItem( + name = "ios", + tagType = "tag" + ) + ), + views = 1, + displayType = + TagGroupDisplayType.TAG + ) + ), + maxViewsForBar = 83 + ), + onShowAllClick = {}, + onRetry = {}, + onRemoveCard = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TagsAndCategoriesCardErrorPreview() { + AppThemeM3 { + TagsAndCategoriesCard( + uiState = TagsAndCategoriesCardUiState + .Error( + message = "Failed to load data" + ), + onShowAllClick = {}, + onRetry = {}, + onRemoveCard = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt new file mode 100644 index 000000000000..4267c49a18fe --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt @@ -0,0 +1,75 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +/** + * UI State for the Tags & Categories insights card. + */ +sealed class TagsAndCategoriesCardUiState { + data object Loading : TagsAndCategoriesCardUiState() + + data class Loaded( + val items: List, + val maxViewsForBar: Long + ) : TagsAndCategoriesCardUiState() + + data class Error( + val message: String + ) : TagsAndCategoriesCardUiState() +} + +/** + * A tag group displayed in the card list. + */ +data class TagGroupUiItem( + val name: String, + val tags: List, + val views: Long, + val displayType: TagGroupDisplayType +) { + val isExpandable: Boolean get() = tags.size > 1 +} + +/** + * A single tag within a tag group. + */ +data class TagUiItem( + val name: String, + val tagType: String +) + +/** + * Display type for a tag group, determines which icon to show. + */ +enum class TagGroupDisplayType { + CATEGORY, + TAG, + MIXED; + + companion object { + private const val CATEGORY_TYPE = "category" + + fun fromTagType( + tagType: String + ): TagGroupDisplayType = + if (tagType == CATEGORY_TYPE) { + CATEGORY + } else { + TAG + } + + fun fromTags( + tags: List + ): TagGroupDisplayType { + val allCategories = tags.all { + it.tagType == CATEGORY_TYPE + } + val allTags = tags.none { + it.tagType == CATEGORY_TYPE + } + return when { + allCategories -> CATEGORY + allTags -> TAG + else -> MIXED + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt new file mode 100644 index 000000000000..545d741a8561 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt @@ -0,0 +1,230 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Sell +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.util.formatStatValue + +private const val VERTICAL_LINE_ALPHA = 0.3f + +@Composable +fun TagTypeIcon( + displayType: TagGroupDisplayType +) { + Icon( + imageVector = when (displayType) { + TagGroupDisplayType.CATEGORY -> + Icons.Outlined.Folder + TagGroupDisplayType.TAG, + TagGroupDisplayType.MIXED -> + Icons.Outlined.Sell + }, + contentDescription = when (displayType) { + TagGroupDisplayType.CATEGORY -> + stringResource( + R.string.stats_tag_type_category + ) + TagGroupDisplayType.TAG, + TagGroupDisplayType.MIXED -> + stringResource( + R.string.stats_tag_type_tag + ) + }, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme + .onSurfaceVariant + ) +} + +@Composable +@Suppress("LongParameterList") +fun TagGroupRow( + item: TagGroupUiItem, + percentage: Float, + isExpandable: Boolean, + isExpanded: Boolean, + onClick: (() -> Unit)?, + position: Int? = null +) { + StatsListRowContainer( + percentage = percentage, + modifier = if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 16.dp + ), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = + Alignment.CenterVertically + ) { + if (position != null) { + Text( + text = "$position", + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme + .colorScheme + .onSurfaceVariant, + modifier = + Modifier.width(28.dp) + ) + } + TagTypeIcon( + displayType = item.displayType + ) + Spacer( + modifier = Modifier.width(8.dp) + ) + if (isExpandable) { + Icon( + imageVector = + if (isExpanded) { + Icons.Default + .KeyboardArrowUp + } else { + Icons.Default + .KeyboardArrowDown + }, + contentDescription = + stringResource( + if (isExpanded) { + R.string + .stats_collapse_group + } else { + R.string + .stats_expand_group + } + ), + modifier = + Modifier.size(16.dp), + tint = MaterialTheme + .colorScheme + .onSurfaceVariant + ) + Spacer( + modifier = + Modifier.width(4.dp) + ) + } + Text( + text = item.name, + style = MaterialTheme.typography + .bodyLarge, + color = MaterialTheme.colorScheme + .onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = formatStatValue(item.views), + style = MaterialTheme.typography + .bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme + .onSurface + ) + } + } +} + +@Composable +fun ExpandedTagsSection( + tags: List, + startPadding: Dp = 24.dp +) { + val lineColor = MaterialTheme.colorScheme.primary + .copy(alpha = VERTICAL_LINE_ALPHA) + + Column( + modifier = Modifier.padding( + start = startPadding, + top = 4.dp, + bottom = 4.dp + ) + ) { + tags.forEachIndexed { index, tag -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = + Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(2.dp) + .height(24.dp) + .background(lineColor) + ) + Spacer( + modifier = Modifier.width(12.dp) + ) + TagTypeIcon( + displayType = + TagGroupDisplayType + .fromTagType(tag.tagType) + ) + Spacer( + modifier = Modifier.width(8.dp) + ) + Text( + text = tag.name, + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme + .onSurface + ) + } + if (index < tags.lastIndex) { + Spacer( + modifier = Modifier.height(2.dp) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt new file mode 100644 index 000000000000..bc2a3be620c5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt @@ -0,0 +1,238 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.ui.newstats.components.StatsListHeader + +private const val DETAIL_EXPANDED_START_PADDING = 52 + +@AndroidEntryPoint +class TagsAndCategoriesDetailActivity : + BaseAppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val items = detailItems + if (items == null) { + finish() + return + } + + setContent { + AppThemeM3 { + TagsAndCategoriesDetailScreen( + items = items, + onBackPressed = onBackPressedDispatcher + ::onBackPressed + ) + } + } + } + + companion object { + private var detailItems: List? = + null + + fun start( + context: Context, + items: List + ) { + detailItems = items + val intent = Intent( + context, + TagsAndCategoriesDetailActivity::class + .java + ) + context.startActivity(intent) + } + + fun clearData() { + detailItems = null + } + } + + override fun onDestroy() { + super.onDestroy() + if (isFinishing) { + clearData() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TagsAndCategoriesDetailScreen( + items: List, + onBackPressed: () -> Unit +) { + val maxViews = + items.firstOrNull()?.views ?: 1L + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource( + R.string + .stats_insights_tags_and_categories + ) + ) + }, + navigationIcon = { + IconButton( + onClick = onBackPressed + ) { + Icon( + Icons.AutoMirrored.Filled + .ArrowBack, + contentDescription = + stringResource( + R.string.back + ) + ) + } + } + ) + } + ) { contentPadding -> + val expandedGroups = remember { + mutableStateMapOf() + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp), + verticalArrangement = + Arrangement.spacedBy(4.dp) + ) { + item { + StatsListHeader( + leftHeaderResId = + R.string + .stats_insights_tags_and_categories, + rightHeaderResId = + R.string.stats_views + ) + Spacer( + modifier = Modifier.height(8.dp) + ) + } + itemsIndexed(items) { index, item -> + val percentage = + if (maxViews > 0) { + item.views.toFloat() / + maxViews.toFloat() + } else { + 0f + } + val isExpanded = + expandedGroups[index] == true + + TagGroupRow( + item = item, + percentage = percentage, + position = index + 1, + isExpandable = item.isExpandable, + isExpanded = isExpanded, + onClick = if (item.isExpandable) { + { + expandedGroups[index] = + !isExpanded + } + } else { + null + } + ) + if (item.isExpandable) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + ExpandedTagsSection( + tags = item.tags, + startPadding = + DETAIL_EXPANDED_START_PADDING + .dp + ) + } + } + } + item { + Spacer( + modifier = Modifier.height(8.dp) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TagsAndCategoriesDetailPreview() { + AppThemeM3 { + TagsAndCategoriesDetailScreen( + items = listOf( + TagGroupUiItem( + name = "Uncategorized", + tags = listOf( + TagUiItem( + name = "Uncategorized", + tagType = "category" + ) + ), + views = 83, + displayType = + TagGroupDisplayType.CATEGORY + ), + TagGroupUiItem( + name = "snaps", + tags = listOf( + TagUiItem( + name = "snaps", + tagType = "tag" + ) + ), + views = 15, + displayType = + TagGroupDisplayType.TAG + ) + ), + onBackPressed = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt new file mode 100644 index 000000000000..c616f6557ddf --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt @@ -0,0 +1,154 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.viewmodel.ResourceProvider +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +@HiltViewModel +class TagsAndCategoriesViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + TagsAndCategoriesCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private var allItems: List = emptyList() + private val isLoaded = AtomicBoolean(false) + private val isLoading = AtomicBoolean(false) + private var fetchJob: Job? = null + + fun loadData() { + if (isLoaded.get() || !isLoading.compareAndSet(false, true)) return + fetchData() + } + + fun refresh() { + fetchJob?.cancel() + isLoaded.set(false) + isLoading.set(true) + _uiState.value = TagsAndCategoriesCardUiState.Loading + fetchData() + } + + fun getDetailData(): List = allItems + + @Suppress("TooGenericExceptionCaught") + private fun fetchData() { + val site = selectedSiteRepository + .getSelectedSite() + if (site == null) { + isLoading.set(false) + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + return + } + + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + isLoading.set(false) + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_api + ) + ) + return + } + + statsRepository.init(accessToken) + + fetchJob = viewModelScope.launch { + try { + val result = statsRepository.fetchTags( + siteId = site.siteId + ) + isLoaded.set(result is TagsResult.Success) + handleResult(result) + } catch (e: Exception) { + _uiState.value = + TagsAndCategoriesCardUiState.Error( + e.message ?: resourceProvider + .getString( + R.string.stats_error_unknown + ) + ) + } finally { + isLoading.set(false) + } + } + } + + private fun handleResult(result: TagsResult) { + when (result) { + is TagsResult.Success -> { + val items = result.data.tagGroups + .map { group -> + val tagUiItems = group.tags + .map { tag -> + TagUiItem( + name = tag.name, + tagType = tag.tagType + ) + } + TagGroupUiItem( + name = tagUiItems.joinToString( + TAGS_SEPARATOR + ) { it.name }, + tags = tagUiItems, + views = group.views, + displayType = + TagGroupDisplayType + .fromTags(tagUiItems) + ) + } + allItems = items + val cardItems = + items.take(CARD_MAX_ITEMS) + _uiState.value = + TagsAndCategoriesCardUiState.Loaded( + items = cardItems, + maxViewsForBar = + cardItems.firstOrNull() + ?.views ?: 1L + ) + } + is TagsResult.Error -> { + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_api + ) + ) + } + } + } + + companion object { + private const val CARD_MAX_ITEMS = 7 + private const val TAGS_SEPARATOR = " / " + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt index 7af1a997a9e4..ce9cd1db5dd9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt @@ -208,4 +208,5 @@ internal fun PostStatus?.toLabel(): Int = when (this) { R.string.post_status_post_trashed is PostStatus.Custom -> 0 null -> 0 + else -> 0 } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 32f44c462eb6..4a85eb8d68ca 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1543,6 +1543,10 @@ %1$s%% of views Most popular time Tags and Categories + Category + Tag + Expand + Collapse Check back when you\'ve published your first post! It\'s been %1$s since %2$s was published. Get the ball rolling and increase your post views by sharing your post: It’s been %1$s since %2$s was published. Here’s how the post performed so far: diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt index 7591f533e4b7..00bf911b2b8d 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 @@ -24,7 +24,8 @@ class InsightsCardsConfigurationTest { InsightsCardType.ALL_TIME_STATS, InsightsCardType.MOST_POPULAR_DAY, InsightsCardType.MOST_POPULAR_TIME, - InsightsCardType.YEAR_IN_REVIEW + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.TAGS_AND_CATEGORIES ) } 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 edbcd9524272..f3d13be72636 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 @@ -91,7 +91,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { InsightsCardType.YEAR_IN_REVIEW, InsightsCardType.ALL_TIME_STATS, InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME + InsightsCardType.MOST_POPULAR_TIME, + InsightsCardType.TAGS_AND_CATEGORIES ) verify(appPrefsWrapper) .setStatsInsightsCardsConfigurationJson( @@ -108,7 +109,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "YEAR_IN_REVIEW", "ALL_TIME_STATS", "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME" + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" ] } """.trimIndent() @@ -127,7 +129,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { InsightsCardType.YEAR_IN_REVIEW, InsightsCardType.ALL_TIME_STATS, InsightsCardType.MOST_POPULAR_DAY, - InsightsCardType.MOST_POPULAR_TIME + InsightsCardType.MOST_POPULAR_TIME, + InsightsCardType.TAGS_AND_CATEGORIES ) verify( appPrefsWrapper, @@ -165,7 +168,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "YEAR_IN_REVIEW", "ALL_TIME_STATS", "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME" + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" ] } """.trimIndent() @@ -232,7 +236,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "YEAR_IN_REVIEW", "ALL_TIME_STATS", "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME" + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" ] } """.trimIndent() @@ -268,7 +273,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "YEAR_IN_REVIEW", "ALL_TIME_STATS", "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME" + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" ] } """.trimIndent() @@ -376,7 +382,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.moveCardDown( TEST_SITE_ID, - InsightsCardType.MOST_POPULAR_TIME + InsightsCardType.TAGS_AND_CATEGORIES ) verify( @@ -422,7 +428,7 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { repository.moveCardToBottom( TEST_SITE_ID, - InsightsCardType.MOST_POPULAR_TIME + InsightsCardType.TAGS_AND_CATEGORIES ) verify( @@ -441,7 +447,8 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { "YEAR_IN_REVIEW", "ALL_TIME_STATS", "MOST_POPULAR_DAY", - "MOST_POPULAR_TIME" + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" ] } """.trimIndent() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt new file mode 100644 index 000000000000..fb89e4c22de9 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt @@ -0,0 +1,279 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsErrorType +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import org.wordpress.android.ui.newstats.datasource.StatsTagsDataResult +import org.wordpress.android.ui.newstats.datasource.TagData +import org.wordpress.android.ui.newstats.datasource.TagGroupData + +@ExperimentalCoroutinesApi +class StatsRepositoryTagsTest : BaseUnitTest() { + @Mock + private lateinit var statsDataSource: StatsDataSource + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + repository = StatsRepository( + statsDataSource = statsDataSource, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + @Test + fun `given success, when fetchTags, then success result`() = + test { + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Success( + createTestTagsData() + ) + ) + + val result = repository.fetchTags( + TEST_SITE_ID + ) + + assertThat(result) + .isInstanceOf( + TagsResult.Success::class.java + ) + val success = result as TagsResult.Success + assertThat(success.data.tagGroups) + .hasSize(2) + } + + @Test + fun `given success, when fetchTags, then data is mapped correctly`() = + test { + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Success( + createTestTagsData() + ) + ) + + val result = repository.fetchTags( + TEST_SITE_ID + ) + + val success = result as TagsResult.Success + val firstGroup = success.data.tagGroups[0] + assertThat(firstGroup.views) + .isEqualTo(TEST_CATEGORY_VIEWS) + assertThat(firstGroup.tags).hasSize(1) + assertThat(firstGroup.tags[0].name) + .isEqualTo(TEST_CATEGORY_NAME) + assertThat(firstGroup.tags[0].tagType) + .isEqualTo("category") + } + + @Test + fun `given error, when fetchTags, then error result`() = + test { + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Error( + StatsErrorType.NETWORK_ERROR + ) + ) + + val result = repository.fetchTags( + TEST_SITE_ID + ) + + assertThat(result) + .isInstanceOf( + TagsResult.Error::class.java + ) + } + + @Test + fun `given auth error, when fetchTags, then error message contains type`() = + test { + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Error( + StatsErrorType.AUTH_ERROR + ) + ) + + val result = repository.fetchTags( + TEST_SITE_ID + ) + + val error = result as TagsResult.Error + assertThat(error.message) + .isEqualTo("AUTH_ERROR") + } + + @Test + fun `given api error, when fetchTags, then error message contains type`() = + test { + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Error( + StatsErrorType.API_ERROR + ) + ) + + val result = repository.fetchTags( + TEST_SITE_ID + ) + + val error = result as TagsResult.Error + assertThat(error.message) + .isEqualTo("API_ERROR") + } + + @Test + fun `when fetchTags, then correct siteId is passed`() = + test { + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Success( + createTestTagsData() + ) + ) + + repository.fetchTags(TEST_SITE_ID) + + verify(statsDataSource).fetchStatsTags( + siteId = eq(TEST_SITE_ID), + max = any() + ) + } + + @Test + fun `given empty tag groups, when fetchTags, then success with empty list`() = + test { + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Success( + StatsTagsData( + tagGroups = emptyList() + ) + ) + ) + + val result = repository.fetchTags( + TEST_SITE_ID + ) + + val success = result as TagsResult.Success + assertThat(success.data.tagGroups).isEmpty() + } + + @Test + fun `given multi-tag group, when fetchTags, then all tags preserved`() = + test { + val multiTagData = StatsTagsData( + tagGroups = listOf( + TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = "Alpha" + ), + TagData( + tagType = "category", + name = "Beta" + ) + ), + views = 50 + ) + ) + ) + whenever( + statsDataSource.fetchStatsTags( + any(), any() + ) + ).thenReturn( + StatsTagsDataResult.Success( + multiTagData + ) + ) + + val result = repository.fetchTags( + TEST_SITE_ID + ) + + val success = result as TagsResult.Success + val group = success.data.tagGroups[0] + assertThat(group.tags).hasSize(2) + assertThat(group.tags[0].name) + .isEqualTo("Alpha") + assertThat(group.tags[1].name) + .isEqualTo("Beta") + } + + private fun createTestTagsData() = StatsTagsData( + tagGroups = listOf( + TagGroupData( + tags = listOf( + TagData( + tagType = "category", + name = TEST_CATEGORY_NAME + ) + ), + views = TEST_CATEGORY_VIEWS + ), + TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = TEST_TAG_NAME + ) + ), + views = TEST_TAG_VIEWS + ) + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_CATEGORY_NAME = + "Uncategorized" + private const val TEST_CATEGORY_VIEWS = 83L + private const val TEST_TAG_NAME = "snaps" + private const val TEST_TAG_VIEWS = 15L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagGroupDisplayTypeTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagGroupDisplayTypeTest.kt new file mode 100644 index 000000000000..900b002e7db3 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagGroupDisplayTypeTest.kt @@ -0,0 +1,130 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class TagGroupDisplayTypeTest { + @Test + fun `when all tags are categories, then CATEGORY`() { + val tags = listOf( + TagUiItem( + name = "Cat1", + tagType = "category" + ), + TagUiItem( + name = "Cat2", + tagType = "category" + ) + ) + + val result = TagGroupDisplayType.fromTags(tags) + + assertThat(result) + .isEqualTo(TagGroupDisplayType.CATEGORY) + } + + @Test + fun `when all tags are tags, then TAG`() { + val tags = listOf( + TagUiItem( + name = "Tag1", + tagType = "tag" + ), + TagUiItem( + name = "Tag2", + tagType = "tag" + ) + ) + + val result = TagGroupDisplayType.fromTags(tags) + + assertThat(result) + .isEqualTo(TagGroupDisplayType.TAG) + } + + @Test + fun `when mixed types, then MIXED`() { + val tags = listOf( + TagUiItem( + name = "Tag1", + tagType = "tag" + ), + TagUiItem( + name = "Cat1", + tagType = "category" + ) + ) + + val result = TagGroupDisplayType.fromTags(tags) + + assertThat(result) + .isEqualTo(TagGroupDisplayType.MIXED) + } + + @Test + fun `when single category, then CATEGORY`() { + val tags = listOf( + TagUiItem( + name = "Cat1", + tagType = "category" + ) + ) + + val result = TagGroupDisplayType.fromTags(tags) + + assertThat(result) + .isEqualTo(TagGroupDisplayType.CATEGORY) + } + + @Test + fun `when single tag, then TAG`() { + val tags = listOf( + TagUiItem( + name = "Tag1", + tagType = "tag" + ) + ) + + val result = TagGroupDisplayType.fromTags(tags) + + assertThat(result) + .isEqualTo(TagGroupDisplayType.TAG) + } + + @Test + fun `when empty list, then CATEGORY`() { + val tags = emptyList() + + val result = TagGroupDisplayType.fromTags(tags) + + assertThat(result) + .isEqualTo(TagGroupDisplayType.CATEGORY) + } + + @Test + fun `when fromTagType with category, then CATEGORY`() { + val result = TagGroupDisplayType + .fromTagType("category") + + assertThat(result) + .isEqualTo(TagGroupDisplayType.CATEGORY) + } + + @Test + fun `when fromTagType with tag, then TAG`() { + val result = TagGroupDisplayType + .fromTagType("tag") + + assertThat(result) + .isEqualTo(TagGroupDisplayType.TAG) + } + + @Test + fun `when fromTagType with unknown, then TAG`() { + val result = TagGroupDisplayType + .fromTagType("unknown") + + assertThat(result) + .isEqualTo(TagGroupDisplayType.TAG) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt new file mode 100644 index 000000000000..6c23d13b06f5 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt @@ -0,0 +1,703 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import org.wordpress.android.ui.newstats.datasource.TagData +import org.wordpress.android.ui.newstats.datasource.TagGroupData +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class TagsAndCategoriesViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: + TagsAndCategoriesViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + whenever(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + } + + private fun stubNoSiteError() { + whenever( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn(NO_SITE_ERROR) + } + + private fun stubApiError() { + whenever( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(API_ERROR) + } + + private fun initViewModel() { + viewModel = TagsAndCategoriesViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + } + + // region Initial state + @Test + fun `initial state is Loading`() { + initViewModel() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loading::class.java + ) + } + // endregion + + // region Error states + @Test + fun `when no site selected, then error state`() = + test { + stubNoSiteError() + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(NO_SITE_ERROR) + } + + @Test + fun `when access token is null, then error state`() = + test { + stubApiError() + whenever(accountStore.accessToken) + .thenReturn(null) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(API_ERROR) + } + + @Test + fun `when access token is empty, then error state`() = + test { + stubApiError() + whenever(accountStore.accessToken) + .thenReturn("") + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(API_ERROR) + } + + @Test + fun `when fetch returns error, then error state`() = + test { + stubApiError() + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Error("Network error") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + } + + @Test + fun `when exception is thrown, then error state with message`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenThrow( + RuntimeException("Test exception") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo("Test exception") + } + // endregion + + // region Success states + @Test + fun `when fetch succeeds, then loaded state`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when fetch succeeds, then items are mapped correctly`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items).hasSize(2) + assertThat(state.items[0].name) + .isEqualTo(TEST_CATEGORY_NAME) + assertThat(state.items[0].views) + .isEqualTo(TEST_CATEGORY_VIEWS) + assertThat(state.items[1].name) + .isEqualTo(TEST_TAG_NAME) + assertThat(state.items[1].views) + .isEqualTo(TEST_TAG_VIEWS) + } + + @Test + fun `when fetch succeeds, then maxViewsForBar is first item views`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.maxViewsForBar) + .isEqualTo(TEST_CATEGORY_VIEWS) + } + + @Test + fun `when more than 7 items, then card shows only 7`() = + test { + val manyGroups = (1..10).map { i -> + TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = "Tag $i" + ) + ), + views = (100 - i).toLong() + ) + } + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData(tagGroups = manyGroups) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items).hasSize(CARD_MAX_ITEMS) + } + + @Test + fun `when multi-tag group, then name is joined with separator`() = + test { + val multiTagGroup = TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = "Alpha" + ), + TagData( + tagType = "category", + name = "Beta" + ) + ), + views = 50 + ) + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = listOf(multiTagGroup) + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items[0].name) + .isEqualTo("Alpha / Beta") + assertThat(state.items[0].tags).hasSize(2) + } + + @Test + fun `when all tags are categories, then displayType is CATEGORY`() = + test { + val group = TagGroupData( + tags = listOf( + TagData( + tagType = "category", + name = "Cat1" + ), + TagData( + tagType = "category", + name = "Cat2" + ) + ), + views = 50 + ) + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = listOf(group) + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items[0].displayType) + .isEqualTo(TagGroupDisplayType.CATEGORY) + } + + @Test + fun `when all tags are tags, then displayType is TAG`() = + test { + val group = TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = "Tag1" + ) + ), + views = 50 + ) + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = listOf(group) + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items[0].displayType) + .isEqualTo(TagGroupDisplayType.TAG) + } + + @Test + fun `when mixed tags, then displayType is MIXED`() = + test { + val group = TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = "Tag1" + ), + TagData( + tagType = "category", + name = "Cat1" + ) + ), + views = 50 + ) + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = listOf(group) + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items[0].displayType) + .isEqualTo(TagGroupDisplayType.MIXED) + } + + @Test + fun `when empty result, then loaded with empty list`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = emptyList() + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items).isEmpty() + assertThat(state.maxViewsForBar).isEqualTo(1L) + } + // endregion + + // region loadData guards + @Test + fun `when loadData called twice, then fetch only once`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + viewModel.loadData() + advanceUntilIdle() + + verify(statsRepository, times(1)) + .fetchTags(eq(TEST_SITE_ID), any()) + } + // endregion + + // region refresh + @Test + fun `when refresh, then data is re-fetched`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + viewModel.refresh() + advanceUntilIdle() + + verify(statsRepository, times(2)) + .fetchTags(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when refresh after error, then loaded state`() = + test { + stubApiError() + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Error("Network error") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + // endregion + + // region refresh sets loading + @Test + fun `when refresh with no site, then loading then error`() = + test { + stubNoSiteError() + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + viewModel.refresh() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + } + // endregion + + // region loadData retries after error + @Test + fun `when loadData after error, then data is re-fetched`() = + test { + stubApiError() + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Error("Network error") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + // endregion + + // region getDetailData + @Test + fun `when getDetailData, then returns all items`() = + test { + val manyGroups = (1..10).map { i -> + TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = "Tag $i" + ) + ), + views = (100 - i).toLong() + ) + } + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData(tagGroups = manyGroups) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val detailData = viewModel.getDetailData() + assertThat(detailData).hasSize(10) + } + + @Test + fun `when getDetailData before load, then empty list`() { + initViewModel() + + val detailData = viewModel.getDetailData() + assertThat(detailData).isEmpty() + } + // endregion + + // region Repository interaction + @Test + fun `when loadData, then init and fetchTags called`() = + test { + whenever( + statsRepository.fetchTags(any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + verify(statsRepository) + .init(TEST_ACCESS_TOKEN) + verify(statsRepository) + .fetchTags(eq(TEST_SITE_ID), any()) + } + // endregion + + private fun createSuccessResult() = + TagsResult.Success( + StatsTagsData( + tagGroups = listOf( + TagGroupData( + tags = listOf( + TagData( + tagType = "category", + name = TEST_CATEGORY_NAME + ) + ), + views = TEST_CATEGORY_VIEWS + ), + TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = TEST_TAG_NAME + ) + ), + views = TEST_TAG_VIEWS + ) + ) + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + private const val NO_SITE_ERROR = + "No site selected" + private const val API_ERROR = + "Failed to load stats" + + private const val TEST_CATEGORY_NAME = + "Uncategorized" + private const val TEST_CATEGORY_VIEWS = 83L + private const val TEST_TAG_NAME = "snaps" + private const val TEST_TAG_VIEWS = 15L + + private const val CARD_MAX_ITEMS = 7 + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf570c9d6f29..38c421876c9f 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-1de57afce924622700bcc8d3a1f3ce893d8dad5b' +wordpress-rs = '1230-d4c9317af369c7231b414998986fd2e773eebf7d' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 897844cefe4440772f2acb07ca11042b54cd8599 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Thu, 19 Mar 2026 09:05:00 +0100 Subject: [PATCH 13/14] CMM-1952: stats insights: tags card and some fixes (#22701) * Add Most Popular Time Insights card Introduce a new "Most popular time" card in the stats insights tab that shows the best day of week and best hour with their view percentages. The card reuses the insights API endpoint via a new shared StatsInsightsUseCase (following the StatsSummaryUseCase caching pattern), which also refactors YearInReviewViewModel to use the same use case. Co-Authored-By: Claude Opus 4.6 * Clean up MostPopularTimeViewModel: locale-aware hour formatting and remove unnecessary @Volatile Use DateTimeFormatter.ofLocalizedTime instead of hardcoded AM/PM to respect device locale settings. Remove unnecessary @Volatile annotations since all access is on the main thread via viewModelScope. Co-Authored-By: Claude Opus 4.6 * Add tests for MostPopularTimeViewModel and StatsInsightsUseCase Co-Authored-By: Claude Opus 4.6 * Fix day-of-week mapping, NoData condition, and add bounds check - Fix day mapping: WordPress API uses 0=Monday (not Sunday) - Show NoData when either day or hour percent is zero (not both) - Add bounds check for invalid day values (returns empty string) - Update and add tests for new behavior Co-Authored-By: Claude Opus 4.6 * Fix detekt: suppress LongMethod and remove unused import Co-Authored-By: Claude Opus 4.6 * Centralize Insights data fetching in InsightsViewModel Move data fetching from individual card ViewModels to InsightsViewModel as coordinator, ensuring each API endpoint (stats summary and insights) is called only once per load. Card ViewModels now receive results via SharedFlow instead of fetching independently, reducing duplicate network calls from 4 to 2. Co-Authored-By: Claude Opus 4.6 * Fix race condition, consistent onRetry pattern, and remove unused siteId - Set isDataLoading in refreshData() to prevent duplicate fetches - Move onRetry from YearInReviewCardUiState.Error to composable param - Remove unused siteId property, use resolvedSiteId() directly Co-Authored-By: Claude Opus 4.6 * Add formatHour bounds check, remove duplicate string, clean up import - Guard formatHour against invalid hour values (crash prevention) - Remove duplicate stats_insights_percent_of_views string resource - Use import for kotlin.math.round instead of fully qualified call Co-Authored-By: Claude Opus 4.6 * Fix detekt LongMethod: extract fetchSummary and fetchInsights Co-Authored-By: Claude Opus 4.6 * Reduce duplication in MostPopularTimeCard using shared components Replace manual card container, header, error content, and shimmer boxes with StatsCardContainer, StatsCardHeader, StatsCardErrorContent, and ShimmerBox. Extract repeated day/hour section into StatSection. Co-Authored-By: Claude Opus 4.6 * Rename views percent string resource and add NoData preview - Rename stats_insights_most_popular_day_percent to stats_insights_views_percent for neutral naming - Add missing NoData preview to MostPopularTimeCard Co-Authored-By: Claude Opus 4.6 * Trigger PR checks * Use device 24h/12h setting for hour formatting Use android.text.format.DateFormat.is24HourFormat() to respect the device time format preference instead of relying on locale. Co-Authored-By: Claude Opus 4.6 * Fix thread safety, CancellationException handling, and lambda allocation - Add @Volatile to isDataLoaded/isDataLoading flags - Rethrow CancellationException to preserve structured concurrency - Wrap onRetryData lambda with remember to avoid recomposition allocations Co-Authored-By: Claude Opus 4.6 * Add Tags & Categories insights card Add a new top-list card to the Insights tab showing tags and categories with view counts. Includes expandable multi-tag groups, percentage bars, folder/tag icons, and a detail screen via Show All. Updates wordpress-rs to 1230 for the statsTags endpoint. Co-Authored-By: Claude Opus 4.6 * Add expand/collapse for multi-tag groups in detail screen Co-Authored-By: Claude Opus 4.6 * Add tests for Tags & Categories feature ViewModel, repository, and display type unit tests covering success/error states, data mapping, refresh, and edge cases. Co-Authored-By: Claude Opus 4.6 * Simplify Tags & Categories by reusing shared components - Use StatsCardContainer, StatsCardHeader, StatsListHeader, StatsCardEmptyContent, StatsListRowContainer from StatsCardCommon - Extract TagTypeIcon and ExpandedTagsSection into shared TagsAndCategoriesComponents to eliminate duplication between Card and DetailActivity - Add fromTagType() to TagGroupDisplayType to avoid list allocation per tag in ExpandedTagsSection - Add modifier parameter to StatsListRowContainer for clickable rows - Remove duplicated constants (CardCornerRadius, BAR_BACKGROUND_ALPHA, VERTICAL_LINE_ALPHA) Co-Authored-By: Claude Opus 4.6 * Remove unused stubUnknownError from ViewModel test Co-Authored-By: Claude Opus 4.6 * Fix review issues: error recovery, conditional refresh, loading state - Only set isLoaded on success so loadData() retries after errors - Guard pull-to-refresh to skip tags refresh when card is hidden - Move loading state into refresh() so callers don't need showLoading() - Remove showLoading() public method - Add test for loadData() retry after error Co-Authored-By: Claude Opus 4.6 * Address review feedback: deduplicate row composable, remove unused link field, fix thread safety and Intent size - Extract shared TagGroupRow composable into TagsAndCategoriesComponents.kt with optional position parameter, removing duplicate from Card and DetailActivity - Remove unused TagData.link field from data source, impl, and all tests - Replace Intent extras with in-memory static holder in DetailActivity to avoid TransactionTooLargeException risk - Remove unnecessary Parcelable from UI models - Use AtomicBoolean for isLoaded/isLoading flags in ViewModel for thread safety Co-Authored-By: Claude Opus 4.6 * Fix concurrent refresh, process death, isExpandable duplication, and accessibility - Cancel in-flight fetch job on refresh() to prevent stale overwrites - Finish detail activity on process death instead of showing blank screen - Extract TagGroupUiItem.isExpandable computed property to deduplicate logic - Add content descriptions for TagTypeIcon and expand/collapse chevron icons Co-Authored-By: Claude Opus 4.6 * Update configuration tests to include TAGS_AND_CATEGORIES card type Fixes CI test failures caused by the new TAGS_AND_CATEGORIES card not being reflected in test fixtures and assertions. Co-Authored-By: Claude Opus 4.6 * Fetch tags data independently in detail screen instead of using static field The detail screen now has its own ViewModel that fetches up to 100 items directly from the API, while the card continues to fetch 10. This removes the static data holder pattern that was prone to data loss on process death. Co-Authored-By: Claude Opus 4.6 * Extract shared mapper, add detail VM tests, guard double calls, show all 10 card items - Extract TagsAndCategoriesMapper to deduplicate TagGroupData to TagGroupUiItem mapping between card and detail ViewModels - Add unit tests for TagsAndCategoriesDetailViewModel - Add isLoaded/isLoading guards to detail VM to prevent double fetches - Remove CARD_MAX_ITEMS limit so card displays all 10 fetched items - Remove unused import in DetailActivity Co-Authored-By: Claude Opus 4.6 * Skip data fetching for hidden Insights cards Only call summary/insights endpoints when visible cards need them, avoiding unnecessary network calls for hidden cards. Track which endpoint groups have been fetched so re-adding a hidden card triggers a fetch for its missing data. Co-Authored-By: Claude Opus 4.6 * Early return in fetchData when no endpoints are needed Prevents setting isDataLoaded=true when no cards require fetching, which would block future fetches when cards are re-added to the visible list. Co-Authored-By: Claude Opus 4.6 * Address code review findings: reduce duplication and improve tests - Replace duplicate shimmer animation in YearInReviewCard with shared rememberShimmerBrush() utility - Extract StatsTagsUseCase to centralize token validation and repository init, removing duplication between Tags ViewModels - Add card reordering tests for middle elements in InsightsCardsConfigurationRepositoryTest - Fix locale-dependent assertions in MostPopularDayViewModelTest Co-Authored-By: Claude Opus 4.6 * Suppress LargeClass detekt warning on InsightsViewModelTest Co-Authored-By: Claude Opus 4.6 * Fix process-death restore and add caching to StatsTagsUseCase - Call loadData() unconditionally in TagsAndCategoriesDetailActivity onCreate to handle process-death restore (loadData guard prevents double fetch on rotation) - Add Mutex-protected in-memory cache to StatsTagsUseCase, consistent with StatsSummaryUseCase and StatsInsightsUseCase - Pass forceRefresh=true on pull-to-refresh in TagsAndCategoriesViewModel Co-Authored-By: Claude Opus 4.6 * Extract BaseTagsAndCategoriesViewModel and use localized error messages Co-Authored-By: Claude Opus 4.6 * Fix thread safety and error handling in ViewModels Use AtomicBoolean with compareAndSet in InsightsViewModel to prevent race conditions, rethrow CancellationException in base tags ViewModel, and only mark endpoints as fetched on success results. Co-Authored-By: Claude Opus 4.6 * Extract isCacheHit method to fix detekt ComplexCondition Co-Authored-By: Claude Opus 4.6 * Cancel in-flight fetch job before refreshing in InsightsViewModel Co-Authored-By: Claude Opus 4.6 * Fix compilation errors after trunk merge Update wordpress-rs to version with stats tags types and remove duplicate NoConnectionContent composable and redundant else branches that caused -Werror failures. Co-Authored-By: Claude Opus 4.6 (1M context) * Address code review findings and add base ViewModel tests - Move network call outside mutex in StatsTagsUseCase to avoid blocking concurrent callers during slow requests - Add main-thread-confinement comments for fetchJob fields - Document why TAGS_AND_CATEGORIES is absent from needsSummary/needsInsights (uses its own fetch path) - Restore card max items to 7 (was unintentionally changed to 10 during refactoring) - Add tests for BaseTagsAndCategoriesViewModel Co-Authored-By: Claude Opus 4.6 (1M context) * Fix detekt findings: suppress ReturnCount, remove unused composable - Suppress ReturnCount on StatsTagsUseCase.invoke (3 returns are clearer than restructuring with nested conditions) - Remove unused PlaceholderTabContent composable Co-Authored-By: Claude Opus 4.6 (1M context) * Fix TOCTOU race, config fetch trigger, and detail empty state - StatsTagsUseCase: use in-flight Deferred to coalesce concurrent requests with the same params, eliminating the TOCTOU race where two callers could both miss cache and fire duplicate requests - BaseTagsAndCategoriesViewModel: make isLoaded private since no subclass accesses it directly - InsightsViewModel: trigger loadDataIfNeeded() from updateFromConfiguration when new endpoints are required, instead of relying on the UI to call it - TagsAndCategoriesDetailActivity: add empty-state message when the loaded items list is empty Co-Authored-By: Claude Opus 4.6 (1M context) * Deduplicate detail VM tests, fix Mockito import, add cancellation test - Remove duplicate tests from TagsAndCategoriesDetailViewModelTest that are already covered by BaseTagsAndCategoriesViewModelTest; keep only initial-state and maxItems-specific tests - Replace fully-qualified org.mockito.Mockito.times() with the imported mockito-kotlin times() throughout InsightsViewModelTest - Add test verifying that rapid double-refresh cancels the first job so only one forceRefresh call completes Co-Authored-By: Claude Opus 4.6 (1M context) * Clear stats caches on screen open to prevent stale data Add clearCache() to StatsInsightsUseCase, StatsSummaryUseCase, and StatsTagsUseCase. Call all three from InsightsViewModel init so that reopening the insights screen always fetches fresh data while still sharing results between cards within a single session. Co-Authored-By: Claude Opus 4.6 (1M context) * Update wordpress-rs to trunk-262a778ead5f163f3450d62adfac21fb32048714 Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 --- .../android/ui/newstats/InsightsViewModel.kt | 99 +++- .../android/ui/newstats/NewStatsActivity.kt | 68 +-- .../repository/StatsInsightsUseCase.kt | 4 + .../repository/StatsSummaryUseCase.kt | 4 + .../newstats/repository/StatsTagsUseCase.kt | 105 ++++ .../BaseTagsAndCategoriesViewModel.kt | 130 +++++ .../TagsAndCategoriesDetailActivity.kt | 346 ++++++++---- .../TagsAndCategoriesDetailViewModel.kt | 26 + .../TagsAndCategoriesMapper.kt | 30 ++ .../TagsAndCategoriesViewModel.kt | 151 +----- .../newstats/yearinreview/YearInReviewCard.kt | 42 +- .../android/ui/postsrs/PostRsListUiState.kt | 1 - WordPress/src/main/res/values/strings.xml | 1 + .../ui/newstats/InsightsViewModelTest.kt | 254 ++++++++- .../MostPopularDayViewModelTest.kt | 11 + ...nsightsCardsConfigurationRepositoryTest.kt | 126 +++++ .../repository/StatsInsightsUseCaseTest.kt | 21 + .../repository/StatsSummaryUseCaseTest.kt | 21 + .../repository/StatsTagsUseCaseTest.kt | 182 +++++++ .../BaseTagsAndCategoriesViewModelTest.kt | 496 ++++++++++++++++++ .../TagsAndCategoriesDetailViewModelTest.kt | 120 +++++ .../TagsAndCategoriesViewModelTest.kt | 218 ++------ gradle/libs.versions.toml | 2 +- 23 files changed, 1896 insertions(+), 562 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCaseTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModelTest.kt 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 de63a40f02d4..136bd30e415f 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 @@ -3,6 +3,7 @@ package org.wordpress.android.ui.newstats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,8 +18,10 @@ import org.wordpress.android.ui.newstats.repository.InsightsResult import org.wordpress.android.ui.newstats.repository.StatsSummaryResult import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase import org.wordpress.android.ui.newstats.repository.StatsInsightsUseCase +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper +import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import javax.inject.Inject @@ -30,7 +33,8 @@ class InsightsViewModel @Inject constructor( InsightsCardsConfigurationRepository, private val networkUtilsWrapper: NetworkUtilsWrapper, private val statsSummaryUseCase: StatsSummaryUseCase, - private val statsInsightsUseCase: StatsInsightsUseCase + private val statsInsightsUseCase: StatsInsightsUseCase, + private val statsTagsUseCase: StatsTagsUseCase ) : ViewModel() { private val _visibleCards = MutableStateFlow>( @@ -68,13 +72,20 @@ class InsightsViewModel @Inject constructor( val isDataRefreshing: StateFlow = _isDataRefreshing.asStateFlow() - @Volatile - private var isDataLoaded = false - - @Volatile - private var isDataLoading = false + private val isDataLoaded = AtomicBoolean(false) + private val isDataLoading = AtomicBoolean(false) + private val summaryFetched = AtomicBoolean(false) + private val insightsFetched = AtomicBoolean(false) + // Main-thread-confined: only accessed from + // viewModelScope (Dispatchers.Main). + private var fetchJob: Job? = null init { + viewModelScope.launch { + statsSummaryUseCase.clearCache() + statsInsightsUseCase.clearCache() + statsTagsUseCase.clearCache() + } checkNetworkStatus() loadConfiguration() observeConfigurationChanges() @@ -90,30 +101,45 @@ class InsightsViewModel @Inject constructor( // region Data fetching fun loadDataIfNeeded() { - if (isDataLoaded || isDataLoading) return - isDataLoading = true + if (isDataLoaded.get() || + !isDataLoading.compareAndSet(false, true) + ) return fetchData() } fun fetchData(forceRefresh: Boolean = false) { val siteId = resolvedSiteId() ?: run { - isDataLoading = false + isDataLoading.set(false) _isDataRefreshing.value = false return } - viewModelScope.launch { + val cards = _cardsToLoad.value + val shouldFetchSummary = cards.needsSummary() + val shouldFetchInsights = cards.needsInsights() + if (!shouldFetchSummary && !shouldFetchInsights) { + isDataLoading.set(false) + _isDataRefreshing.value = false + return + } + fetchJob = viewModelScope.launch { try { coroutineScope { - launch { - fetchSummary(siteId, forceRefresh) + if (shouldFetchSummary) { + launch { + fetchSummary(siteId, forceRefresh) + } } - launch { - fetchInsights(siteId, forceRefresh) + if (shouldFetchInsights) { + launch { + fetchInsights( + siteId, forceRefresh + ) + } } } - isDataLoaded = true + isDataLoaded.set(true) } finally { - isDataLoading = false + isDataLoading.set(false) _isDataRefreshing.value = false } } @@ -131,6 +157,9 @@ class InsightsViewModel @Inject constructor( val result = statsSummaryUseCase( siteId, forceRefresh ) + if (result is StatsSummaryResult.Success) { + summaryFetched.set(true) + } _summaryResult.emit(result) } catch (e: Exception) { if (e is CancellationException) throw e @@ -160,6 +189,9 @@ class InsightsViewModel @Inject constructor( val result = statsInsightsUseCase( siteId, forceRefresh ) + if (result is InsightsResult.Success) { + insightsFetched.set(true) + } _insightsResult.emit(result) } catch (e: Exception) { if (e is CancellationException) throw e @@ -178,8 +210,11 @@ class InsightsViewModel @Inject constructor( } fun refreshData() { - isDataLoaded = false - isDataLoading = true + fetchJob?.cancel() + isDataLoaded.set(false) + summaryFetched.set(false) + insightsFetched.set(false) + isDataLoading.set(true) _isDataRefreshing.value = true fetchData(forceRefresh = true) } @@ -224,7 +259,17 @@ class InsightsViewModel @Inject constructor( ) { _visibleCards.value = config.visibleCards _hiddenCards.value = config.computeHiddenCards() + val cards = config.visibleCards + val needsNewFetch = + (cards.needsSummary() && + !summaryFetched.get()) || + (cards.needsInsights() && + !insightsFetched.get()) _cardsToLoad.value = config.visibleCards + if (needsNewFetch) { + isDataLoaded.set(false) + loadDataIfNeeded() + } } fun removeCard(cardType: InsightsCardType) { @@ -287,4 +332,22 @@ class InsightsViewModel @Inject constructor( null } } + + companion object { + // TAGS_AND_CATEGORIES is intentionally absent + // from both checks: it has its own dedicated + // fetch path via StatsTagsUseCase in + // TagsAndCategoriesViewModel. + private fun List.needsSummary(): + Boolean = any { + it == InsightsCardType.ALL_TIME_STATS || + it == InsightsCardType.MOST_POPULAR_DAY + } + + private fun List.needsInsights(): + Boolean = any { + it == InsightsCardType.YEAR_IN_REVIEW || + it == InsightsCardType.MOST_POPULAR_TIME + } + } } 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 b15367e2a18b..dd7f0f498369 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 @@ -279,7 +279,6 @@ private fun StatsTabContent( ) StatsTab.INSIGHTS -> InsightsTabContent() StatsTab.SUBSCRIBERS -> SubscribersTabContent() - else -> PlaceholderTabContent(tab) } } @@ -1227,14 +1226,8 @@ private fun InsightsTabContent( uiState = tagsAndCategoriesUiState, onShowAllClick = { - val items = - tagsAndCategoriesViewModel - .getDetailData() TagsAndCategoriesDetailActivity - .start( - context, - items - ) + .start(context) }, onRemoveCard = { insightsViewModel @@ -1357,65 +1350,6 @@ private fun AddInsightsCardBottomSheet( } } -@Composable -private fun NoConnectionContent( - onRetry: () -> Unit -) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_wifi_off_24px), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .padding(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.no_connection_error_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.no_connection_error_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = onRetry) { - Text(stringResource(R.string.retry)) - } - } - } -} - -@Composable -private fun PlaceholderTabContent(tab: StatsTab) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(text = "${stringResource(id = tab.titleResId)} - Coming Soon") - } -} - @Composable private fun StatsPeriodMenu( expanded: Boolean, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt index 49dc9bc69331..b1b701be8617 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt @@ -44,4 +44,8 @@ class StatsInsightsUseCase @Inject constructor( result } } + + suspend fun clearCache() { + mutex.withLock { cachedInsights = null } + } } 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 index bbdff27903eb..42d832ab36a1 100644 --- 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 @@ -44,4 +44,8 @@ class StatsSummaryUseCase @Inject constructor( result } } + + suspend fun clearCache() { + mutex.withLock { cachedSummary = null } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt new file mode 100644 index 000000000000..5dc0b868e2e2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt @@ -0,0 +1,105 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatsTagsUseCase @Inject constructor( + private val statsRepository: StatsRepository, + private val accountStore: AccountStore +) { + private val mutex = Mutex() + private var cachedTags: + Triple? = null + + // In-flight request keyed by (siteId, max). + // Concurrent callers with the same params join + // the existing request instead of duplicating it. + private var inFlight: + Pair, + CompletableDeferred>? = null + + @Suppress("ReturnCount") + suspend operator fun invoke( + siteId: Long, + max: Int = DEFAULT_MAX_ITEMS, + forceRefresh: Boolean = false + ): TagsResult { + val token = accountStore.accessToken + if (token.isNullOrEmpty()) { + return TagsResult.Error("No access token") + } + statsRepository.init(token) + + val key = siteId to max + + // Under lock: check cache, then check/create + // an in-flight deferred. The actual network + // call runs outside the lock so concurrent + // callers with different params aren't blocked. + val (deferred, isOwner) = mutex.withLock { + val cached = cachedTags + if (!forceRefresh && + isCacheHit(cached, siteId, max) + ) { + return TagsResult.Success( + cached!!.third + ) + } + + val existing = inFlight + if (!forceRefresh && + existing != null && + existing.first == key + ) { + return@withLock existing.second to false + } + + val newDeferred = + CompletableDeferred() + inFlight = key to newDeferred + newDeferred to true + } + + if (isOwner) { + val result = statsRepository.fetchTags( + siteId = siteId, + max = max + ) + mutex.withLock { + if (result is TagsResult.Success) { + cachedTags = + Triple(siteId, max, result.data) + } + inFlight = null + } + deferred.complete(result) + } + + return deferred.await() + } + + suspend fun clearCache() { + mutex.withLock { + cachedTags = null + inFlight = null + } + } + + private fun isCacheHit( + cached: Triple?, + siteId: Long, + max: Int + ): Boolean = cached != null && + cached.first == siteId && + cached.second == max + + companion object { + private const val DEFAULT_MAX_ITEMS = 10 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModel.kt new file mode 100644 index 000000000000..9949fe3bc7c6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModel.kt @@ -0,0 +1,130 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.util.AppLog +import org.wordpress.android.viewmodel.ResourceProvider +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException + +abstract class BaseTagsAndCategoriesViewModel( + private val selectedSiteRepository: + SelectedSiteRepository, + private val statsTagsUseCase: StatsTagsUseCase, + private val resourceProvider: ResourceProvider, + private val mapper: TagsAndCategoriesMapper +) : ViewModel() { + private val _uiState = + MutableStateFlow( + TagsAndCategoriesCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val isLoaded = AtomicBoolean(false) + private val isLoading = AtomicBoolean(false) + // Main-thread-confined: only accessed from + // viewModelScope (Dispatchers.Main). + private var fetchJob: Job? = null + + protected abstract val maxItems: Int + + fun loadData() { + if (isLoaded.get() || + !isLoading.compareAndSet(false, true) + ) return + fetchData() + } + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + protected fun fetchData( + forceRefresh: Boolean = false + ) { + val site = selectedSiteRepository + .getSelectedSite() + if (site == null) { + isLoading.set(false) + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + return + } + + fetchJob = viewModelScope.launch { + try { + val result = statsTagsUseCase( + siteId = site.siteId, + max = maxItems, + forceRefresh = forceRefresh + ) + isLoaded.set( + result is TagsResult.Success + ) + handleResult(result) + } catch (e: Exception) { + if (e is CancellationException) throw e + AppLog.e( + AppLog.T.STATS, + "Error fetching tags: ${e.message}", + e + ) + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ) + } finally { + isLoading.set(false) + } + } + } + + protected fun resetForRefresh() { + fetchJob?.cancel() + isLoaded.set(false) + isLoading.set(true) + _uiState.value = + TagsAndCategoriesCardUiState.Loading + } + + private fun handleResult(result: TagsResult) { + when (result) { + is TagsResult.Success -> { + val items = mapper.mapToUiItems( + result.data.tagGroups + ) + _uiState.value = + TagsAndCategoriesCardUiState.Loaded( + items = items, + maxViewsForBar = + items.firstOrNull() + ?.views ?: 1L + ) + } + is TagsResult.Error -> { + _uiState.value = + TagsAndCategoriesCardUiState.Error( + resourceProvider.getString( + R.string.stats_error_api + ) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt index bc2a3be620c5..c84ddc8cd1f8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt @@ -4,27 +4,37 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -34,41 +44,40 @@ import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.util.ShimmerBox private const val DETAIL_EXPANDED_START_PADDING = 52 +private const val LOADING_SHIMMER_ITEM_COUNT = 10 @AndroidEntryPoint class TagsAndCategoriesDetailActivity : BaseAppCompatActivity() { + private val viewModel: + TagsAndCategoriesDetailViewModel + by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val items = detailItems - if (items == null) { - finish() - return - } + viewModel.loadData() setContent { AppThemeM3 { + val uiState by viewModel.uiState + .collectAsState() TagsAndCategoriesDetailScreen( - items = items, - onBackPressed = onBackPressedDispatcher - ::onBackPressed + uiState = uiState, + onBackPressed = + onBackPressedDispatcher + ::onBackPressed, + onRetry = { viewModel.loadData() } ) } } } companion object { - private var detailItems: List? = - null - - fun start( - context: Context, - items: List - ) { - detailItems = items + fun start(context: Context) { val intent = Intent( context, TagsAndCategoriesDetailActivity::class @@ -76,29 +85,16 @@ class TagsAndCategoriesDetailActivity : ) context.startActivity(intent) } - - fun clearData() { - detailItems = null - } - } - - override fun onDestroy() { - super.onDestroy() - if (isFinishing) { - clearData() - } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TagsAndCategoriesDetailScreen( - items: List, - onBackPressed: () -> Unit + uiState: TagsAndCategoriesCardUiState, + onBackPressed: () -> Unit, + onRetry: () -> Unit ) { - val maxViews = - items.firstOrNull()?.views ?: 1L - Scaffold( topBar = { TopAppBar( @@ -127,76 +123,187 @@ private fun TagsAndCategoriesDetailScreen( ) } ) { contentPadding -> - val expandedGroups = remember { - mutableStateMapOf() + when (uiState) { + is TagsAndCategoriesCardUiState.Loading -> + DetailLoadingContent( + modifier = Modifier + .padding(contentPadding) + ) + is TagsAndCategoriesCardUiState.Loaded -> + DetailLoadedContent( + items = uiState.items, + maxViews = uiState.maxViewsForBar, + modifier = Modifier + .padding(contentPadding) + ) + is TagsAndCategoriesCardUiState.Error -> + DetailErrorContent( + message = uiState.message, + onRetry = onRetry, + modifier = Modifier + .padding(contentPadding) + ) + } + } +} + +@Composable +private fun DetailLoadingContent( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + ) { + StatsListHeader( + leftHeaderResId = + R.string + .stats_insights_tags_and_categories, + rightHeaderResId = + R.string.stats_views + ) + Spacer(modifier = Modifier.height(8.dp)) + repeat(LOADING_SHIMMER_ITEM_COUNT) { + Spacer(modifier = Modifier.height(12.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + ) } + } +} - LazyColumn( - modifier = Modifier +@Composable +private fun DetailLoadedContent( + items: List, + maxViews: Long, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) { + Box( + modifier = modifier .fillMaxSize() - .padding(contentPadding) - .padding(horizontal = 16.dp), - verticalArrangement = - Arrangement.spacedBy(4.dp) + .padding(16.dp), + contentAlignment = Alignment.Center ) { - item { - StatsListHeader( - leftHeaderResId = - R.string - .stats_insights_tags_and_categories, - rightHeaderResId = - R.string.stats_views - ) - Spacer( - modifier = Modifier.height(8.dp) - ) - } - itemsIndexed(items) { index, item -> - val percentage = - if (maxViews > 0) { - item.views.toFloat() / - maxViews.toFloat() - } else { - 0f - } - val isExpanded = - expandedGroups[index] == true + Text( + text = stringResource( + R.string + .stats_insights_tags_empty + ), + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + return + } - TagGroupRow( - item = item, - percentage = percentage, - position = index + 1, - isExpandable = item.isExpandable, - isExpanded = isExpanded, - onClick = if (item.isExpandable) { - { - expandedGroups[index] = - !isExpanded - } - } else { - null - } - ) - if (item.isExpandable) { - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically(), - exit = shrinkVertically() - ) { - ExpandedTagsSection( - tags = item.tags, - startPadding = - DETAIL_EXPANDED_START_PADDING - .dp - ) + val expandedGroups = remember { + mutableStateMapOf() + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = + Arrangement.spacedBy(4.dp) + ) { + item { + StatsListHeader( + leftHeaderResId = + R.string + .stats_insights_tags_and_categories, + rightHeaderResId = + R.string.stats_views + ) + Spacer( + modifier = Modifier.height(8.dp) + ) + } + itemsIndexed(items) { index, item -> + val percentage = + if (maxViews > 0) { + item.views.toFloat() / + maxViews.toFloat() + } else { + 0f + } + val isExpanded = + expandedGroups[index] == true + + TagGroupRow( + item = item, + percentage = percentage, + position = index + 1, + isExpandable = item.isExpandable, + isExpanded = isExpanded, + onClick = if (item.isExpandable) { + { + expandedGroups[index] = + !isExpanded } + } else { + null + } + ) + if (item.isExpandable) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + ExpandedTagsSection( + tags = item.tags, + startPadding = + DETAIL_EXPANDED_START_PADDING + .dp + ) } } - item { - Spacer( - modifier = Modifier.height(8.dp) + } + item { + Spacer( + modifier = Modifier.height(8.dp) + ) + } + } +} + +@Composable +private fun DetailErrorContent( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = + Alignment.CenterHorizontally, + verticalArrangement = + Arrangement.Center + ) { + Text( + text = message, + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text( + text = stringResource( + R.string.retry ) - } + ) } } } @@ -206,33 +313,40 @@ private fun TagsAndCategoriesDetailScreen( private fun TagsAndCategoriesDetailPreview() { AppThemeM3 { TagsAndCategoriesDetailScreen( - items = listOf( - TagGroupUiItem( - name = "Uncategorized", - tags = listOf( - TagUiItem( + uiState = + TagsAndCategoriesCardUiState.Loaded( + items = listOf( + TagGroupUiItem( name = "Uncategorized", - tagType = "category" - ) - ), - views = 83, - displayType = - TagGroupDisplayType.CATEGORY - ), - TagGroupUiItem( - name = "snaps", - tags = listOf( - TagUiItem( + tags = listOf( + TagUiItem( + name = + "Uncategorized", + tagType = "category" + ) + ), + views = 83, + displayType = + TagGroupDisplayType + .CATEGORY + ), + TagGroupUiItem( name = "snaps", - tagType = "tag" + tags = listOf( + TagUiItem( + name = "snaps", + tagType = "tag" + ) + ), + views = 15, + displayType = + TagGroupDisplayType.TAG ) ), - views = 15, - displayType = - TagGroupDisplayType.TAG - ) - ), - onBackPressed = {} + maxViewsForBar = 83 + ), + onBackPressed = {}, + onRetry = {} ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModel.kt new file mode 100644 index 000000000000..aec0d51b6496 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModel.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import dagger.hilt.android.lifecycle.HiltViewModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +@HiltViewModel +class TagsAndCategoriesDetailViewModel @Inject constructor( + selectedSiteRepository: SelectedSiteRepository, + statsTagsUseCase: StatsTagsUseCase, + resourceProvider: ResourceProvider, + mapper: TagsAndCategoriesMapper +) : BaseTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper +) { + override val maxItems: Int = DETAIL_MAX_ITEMS + + companion object { + private const val DETAIL_MAX_ITEMS = 100 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt new file mode 100644 index 000000000000..93620da3f08b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import org.wordpress.android.ui.newstats.datasource.TagGroupData +import javax.inject.Inject + +class TagsAndCategoriesMapper @Inject constructor() { + fun mapToUiItems( + tagGroups: List + ): List = tagGroups.map { group -> + val tagUiItems = group.tags.map { tag -> + TagUiItem( + name = tag.name, + tagType = tag.tagType + ) + } + TagGroupUiItem( + name = tagUiItems.joinToString( + TAGS_SEPARATOR + ) { it.name }, + tags = tagUiItems, + views = group.views, + displayType = + TagGroupDisplayType.fromTags(tagUiItems) + ) + } + + companion object { + private const val TAGS_SEPARATOR = " / " + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt index c616f6557ddf..25f71c85b5f4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt @@ -1,154 +1,31 @@ package org.wordpress.android.ui.newstats.tagsandcategories -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.wordpress.android.R -import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.ui.newstats.repository.StatsRepository -import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.viewmodel.ResourceProvider -import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @HiltViewModel class TagsAndCategoriesViewModel @Inject constructor( - private val selectedSiteRepository: - SelectedSiteRepository, - private val accountStore: AccountStore, - private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider -) : ViewModel() { - private val _uiState = - MutableStateFlow( - TagsAndCategoriesCardUiState.Loading - ) - val uiState: StateFlow = - _uiState.asStateFlow() - - private var allItems: List = emptyList() - private val isLoaded = AtomicBoolean(false) - private val isLoading = AtomicBoolean(false) - private var fetchJob: Job? = null - - fun loadData() { - if (isLoaded.get() || !isLoading.compareAndSet(false, true)) return - fetchData() - } + selectedSiteRepository: SelectedSiteRepository, + statsTagsUseCase: StatsTagsUseCase, + resourceProvider: ResourceProvider, + mapper: TagsAndCategoriesMapper +) : BaseTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper +) { + override val maxItems: Int = CARD_MAX_ITEMS fun refresh() { - fetchJob?.cancel() - isLoaded.set(false) - isLoading.set(true) - _uiState.value = TagsAndCategoriesCardUiState.Loading - fetchData() - } - - fun getDetailData(): List = allItems - - @Suppress("TooGenericExceptionCaught") - private fun fetchData() { - val site = selectedSiteRepository - .getSelectedSite() - if (site == null) { - isLoading.set(false) - _uiState.value = - TagsAndCategoriesCardUiState.Error( - resourceProvider.getString( - R.string.stats_error_no_site - ) - ) - return - } - - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) { - isLoading.set(false) - _uiState.value = - TagsAndCategoriesCardUiState.Error( - resourceProvider.getString( - R.string.stats_error_api - ) - ) - return - } - - statsRepository.init(accessToken) - - fetchJob = viewModelScope.launch { - try { - val result = statsRepository.fetchTags( - siteId = site.siteId - ) - isLoaded.set(result is TagsResult.Success) - handleResult(result) - } catch (e: Exception) { - _uiState.value = - TagsAndCategoriesCardUiState.Error( - e.message ?: resourceProvider - .getString( - R.string.stats_error_unknown - ) - ) - } finally { - isLoading.set(false) - } - } - } - - private fun handleResult(result: TagsResult) { - when (result) { - is TagsResult.Success -> { - val items = result.data.tagGroups - .map { group -> - val tagUiItems = group.tags - .map { tag -> - TagUiItem( - name = tag.name, - tagType = tag.tagType - ) - } - TagGroupUiItem( - name = tagUiItems.joinToString( - TAGS_SEPARATOR - ) { it.name }, - tags = tagUiItems, - views = group.views, - displayType = - TagGroupDisplayType - .fromTags(tagUiItems) - ) - } - allItems = items - val cardItems = - items.take(CARD_MAX_ITEMS) - _uiState.value = - TagsAndCategoriesCardUiState.Loaded( - items = cardItems, - maxViewsForBar = - cardItems.firstOrNull() - ?.views ?: 1L - ) - } - is TagsResult.Error -> { - _uiState.value = - TagsAndCategoriesCardUiState.Error( - resourceProvider.getString( - R.string.stats_error_api - ) - ) - } - } + resetForRefresh() + fetchData(forceRefresh = true) } companion object { private const val CARD_MAX_ITEMS = 7 - private const val TAGS_SEPARATOR = " / " } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt index 37e1e951a117..29ab2103c0cc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -1,12 +1,7 @@ package org.wordpress.android.ui.newstats.yearinreview import androidx.annotation.StringRes -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import org.wordpress.android.ui.newstats.util.rememberShimmerBrush import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -30,12 +25,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -112,42 +104,13 @@ fun YearInReviewCard( @Composable private fun LoadingContent() { - val shimmerColors = listOf( - MaterialTheme.colorScheme.surfaceVariant - .copy(alpha = 0.3f), - MaterialTheme.colorScheme.surfaceVariant - .copy(alpha = 0.6f), - MaterialTheme.colorScheme.surfaceVariant - .copy(alpha = 0.3f) - ) - - val transition = - rememberInfiniteTransition(label = "shimmer") - val translateAnimation by transition.animateFloat( - initialValue = 0f, - targetValue = 1000f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1200, - easing = LinearEasing - ), - repeatMode = RepeatMode.Restart - ), - label = "shimmer_translate" - ) - - val shimmerBrush = Brush.linearGradient( - colors = shimmerColors, - start = Offset(translateAnimation - 500f, 0f), - end = Offset(translateAnimation, 0f) - ) + val shimmerBrush = rememberShimmerBrush() Column( modifier = Modifier .fillMaxWidth() .padding(CardPadding) ) { - // Title shimmer Box( modifier = Modifier .width(140.dp) @@ -156,7 +119,6 @@ private fun LoadingContent() { .background(shimmerBrush) ) Spacer(modifier = Modifier.height(16.dp)) - // 2x2 grid shimmer repeat(2) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt index 4a457c8d8aad..b43a8ef055b2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/postsrs/PostRsListUiState.kt @@ -209,5 +209,4 @@ internal fun PostStatus?.toLabel(): Int = when (this) { is PostStatus.Any -> 0 is PostStatus.Custom -> 0 null -> 0 - else -> 0 } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 0e292ad7a4a4..6b3781a272e2 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1543,6 +1543,7 @@ %1$s%% of views Most popular time Tags and Categories + No tags or categories found Category Tag Expand 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 7508b02f36e7..c36615145ddf 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 @@ -13,6 +13,7 @@ import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.eq 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 @@ -25,8 +26,10 @@ import org.wordpress.android.ui.newstats.repository.InsightsResult import org.wordpress.android.ui.newstats.repository.StatsSummaryResult import org.wordpress.android.ui.newstats.repository.StatsSummaryUseCase import org.wordpress.android.ui.newstats.repository.StatsInsightsUseCase +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.util.NetworkUtilsWrapper +@Suppress("LargeClass") @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner.Silent::class) class InsightsViewModelTest : @@ -51,6 +54,10 @@ class InsightsViewModelTest : private lateinit var statsInsightsUseCase: StatsInsightsUseCase + @Mock + private lateinit var statsTagsUseCase: + StatsTagsUseCase + private lateinit var viewModel: InsightsViewModel private val testSite = SiteModel().apply { @@ -90,7 +97,8 @@ class InsightsViewModelTest : cardConfigurationRepository, networkUtilsWrapper, statsSummaryUseCase, - statsInsightsUseCase + statsInsightsUseCase, + statsTagsUseCase ) } @@ -238,7 +246,8 @@ class InsightsViewModelTest : cardConfigurationRepository, networkUtilsWrapper, statsSummaryUseCase, - statsInsightsUseCase + statsInsightsUseCase, + statsTagsUseCase ) advanceUntilIdle() @@ -353,7 +362,8 @@ class InsightsViewModelTest : cardConfigurationRepository, networkUtilsWrapper, statsSummaryUseCase, - statsInsightsUseCase + statsInsightsUseCase, + statsTagsUseCase ) assertThat(viewModel.cardsToLoad.value) @@ -490,10 +500,10 @@ class InsightsViewModelTest : advanceUntilIdle() verify(statsSummaryUseCase, - org.mockito.Mockito.times(1)) + times(1)) .invoke(any(), any()) verify(statsInsightsUseCase, - org.mockito.Mockito.times(1)) + times(1)) .invoke(any(), any()) } @@ -625,13 +635,16 @@ class InsightsViewModelTest : @Test fun `when no site selected, then fetchData is no-op`() = test { - initViewModel() - advanceUntilIdle() - whenever( selectedSiteRepository.getSelectedSite() ).thenReturn(null) + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + initViewModel(config) + advanceUntilIdle() + viewModel.fetchData() advanceUntilIdle() @@ -639,6 +652,231 @@ class InsightsViewModelTest : .invoke(any(), any()) } + @Test + fun `when all cards hidden, then no endpoints are called`() = + test { + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase, never()) + .invoke(any(), any()) + verify(statsInsightsUseCase, never()) + .invoke(any(), any()) + } + + @Test + fun `when only summary cards visible, then only summary is fetched`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.ALL_TIME_STATS, + InsightsCardType.MOST_POPULAR_DAY + ) + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsInsightsUseCase, never()) + .invoke(any(), any()) + } + + @Test + fun `when only insights cards visible, then only insights is fetched`() = + test { + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.MOST_POPULAR_TIME + ) + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsInsightsUseCase) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsSummaryUseCase, never()) + .invoke(any(), any()) + } + + @Test + fun `when hidden card re-added, then its endpoint is fetched`() = + test { + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + + // Start with only insights cards + val config = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW + ) + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsInsightsUseCase, + times(1)) + .invoke(any(), any()) + verify(statsSummaryUseCase, never()) + .invoke(any(), any()) + + // Now add a summary card via config change + val newConfig = InsightsCardsConfiguration( + visibleCards = listOf( + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.ALL_TIME_STATS + ) + ) + configurationFlow.value = + TEST_SITE_ID to newConfig + advanceUntilIdle() + + // loadDataIfNeeded should now fetch + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsSummaryUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(false)) + } + + @Test + fun `when refresh called, then all visible endpoints are re-fetched`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + verify(statsSummaryUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsSummaryUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(true)) + verify(statsInsightsUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(false)) + verify(statsInsightsUseCase, + times(1)) + .invoke(eq(TEST_SITE_ID), eq(true)) + } + + @Test + fun `when refresh called twice rapidly, then second refresh replaces first`() = + test { + whenever( + statsSummaryUseCase(any(), any()) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummaryData() + ) + ) + whenever( + statsInsightsUseCase(any(), any()) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + initViewModel() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + // Two rapid refreshes — second should + // cancel the first via fetchJob?.cancel(). + viewModel.refreshData() + viewModel.refreshData() + advanceUntilIdle() + + // The second refreshData() cancels the + // first job before it executes, so only + // one forceRefresh=true call completes. + assertThat( + viewModel.isDataRefreshing.value + ).isFalse() + + verify(statsSummaryUseCase, times(1)) + .invoke(eq(TEST_SITE_ID), eq(true)) + } + + @Test + fun `when initialized, then all caches are cleared`() = + test { + initViewModel() + advanceUntilIdle() + + verify(statsSummaryUseCase).clearCache() + verify(statsInsightsUseCase).clearCache() + verify(statsTagsUseCase).clearCache() + } + // endregion companion object { 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 index 82038fee50a7..5d188df4edd4 100644 --- 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 @@ -2,6 +2,7 @@ package org.wordpress.android.ui.newstats.mostpopularday import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat +import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.Mock @@ -11,6 +12,7 @@ import org.wordpress.android.R import org.wordpress.android.ui.newstats.datasource.StatsSummaryData import org.wordpress.android.ui.newstats.repository.StatsSummaryResult import org.wordpress.android.viewmodel.ResourceProvider +import java.util.Locale @ExperimentalCoroutinesApi class MostPopularDayViewModelTest : BaseUnitTest() { @@ -21,8 +23,12 @@ class MostPopularDayViewModelTest : BaseUnitTest() { private lateinit var viewModel: MostPopularDayViewModel + private lateinit var originalLocale: Locale + @Before fun setUp() { + originalLocale = Locale.getDefault() + Locale.setDefault(Locale.US) lenient().`when`( resourceProvider.getString( R.string.stats_error_api @@ -33,6 +39,11 @@ class MostPopularDayViewModelTest : BaseUnitTest() { ) } + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + @Test fun `initial state is Loading`() { assertThat(viewModel.uiState.value) 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 f3d13be72636..7fef1e148089 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 @@ -439,6 +439,132 @@ class InsightsCardsConfigurationRepositoryTest : BaseUnitTest() { ) } + @Test + fun `when moveCardUp on middle card, then card swaps with previous`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardUp( + TEST_SITE_ID, + InsightsCardType.ALL_TIME_STATS + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards[0]) + .isEqualTo(InsightsCardType.ALL_TIME_STATS) + assertThat(saved.visibleCards[1]) + .isEqualTo(InsightsCardType.YEAR_IN_REVIEW) + } + + @Test + fun `when moveCardDown on middle card, then card swaps with next`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardDown( + TEST_SITE_ID, + InsightsCardType.ALL_TIME_STATS + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards[1]) + .isEqualTo( + InsightsCardType.MOST_POPULAR_DAY + ) + assertThat(saved.visibleCards[2]) + .isEqualTo(InsightsCardType.ALL_TIME_STATS) + } + + @Test + fun `when moveCardToTop on middle card, then card becomes first`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardToTop( + TEST_SITE_ID, + InsightsCardType.MOST_POPULAR_DAY + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards[0]) + .isEqualTo( + InsightsCardType.MOST_POPULAR_DAY + ) + } + + @Test + fun `when moveCardToBottom on middle card, then card becomes last`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardToBottom( + TEST_SITE_ID, + InsightsCardType.MOST_POPULAR_DAY + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + val saved = com.google.gson.Gson() + .fromJson( + jsonCaptor.firstValue, + InsightsCardsConfiguration::class.java + ) + assertThat(saved.visibleCards.last()) + .isEqualTo( + InsightsCardType.MOST_POPULAR_DAY + ) + } + companion object { private const val TEST_SITE_ID = 123L private val ALL_CARDS_JSON = """ diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt index 22b41d2fce86..fffa9fac7450 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt @@ -202,6 +202,27 @@ class StatsInsightsUseCaseTest : BaseUnitTest() { assertThat(success.data.years).hasSize(1) } + @Test + fun `when clearCache called, then next call fetches again`() = + test { + whenever( + statsRepository.fetchInsights( + TEST_SITE_ID + ) + ).thenReturn( + InsightsResult.Success( + createTestInsightsData() + ) + ) + + useCase(TEST_SITE_ID) + useCase.clearCache() + useCase(TEST_SITE_ID) + + verify(statsRepository, times(2)) + .fetchInsights(TEST_SITE_ID) + } + private fun createTestInsightsData() = StatsInsightsData( highestHour = TEST_HIGHEST_HOUR, 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 index b4181e5ffaa1..dbd6d461de2a 100644 --- 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 @@ -136,6 +136,27 @@ class StatsSummaryUseCaseTest : BaseUnitTest() { .fetchStatsSummary(TEST_SITE_ID) } + @Test + fun `when clearCache called, then next call fetches again`() = + test { + whenever( + statsRepository.fetchStatsSummary( + TEST_SITE_ID + ) + ).thenReturn( + StatsSummaryResult.Success( + createTestSummary() + ) + ) + + useCase(TEST_SITE_ID) + useCase.clearCache() + useCase(TEST_SITE_ID) + + verify(statsRepository, times(2)) + .fetchStatsSummary(TEST_SITE_ID) + } + private fun createTestSummary() = StatsSummaryData( views = 100L, visitors = 50L, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCaseTest.kt new file mode 100644 index 000000000000..ed75d4e88a98 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCaseTest.kt @@ -0,0 +1,182 @@ +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.StatsTagsData + +@ExperimentalCoroutinesApi +class StatsTagsUseCaseTest : BaseUnitTest() { + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var accountStore: AccountStore + + private lateinit var useCase: StatsTagsUseCase + + @Before + fun setUp() { + whenever(accountStore.accessToken) + .thenReturn(TEST_ACCESS_TOKEN) + useCase = StatsTagsUseCase( + statsRepository, + accountStore + ) + } + + @Test + fun `when called, then returns cached on second call`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + val first = useCase(TEST_SITE_ID) + val second = useCase(TEST_SITE_ID) + + assertThat(first).isInstanceOf( + TagsResult.Success::class.java + ) + assertThat(second).isInstanceOf( + TagsResult.Success::class.java + ) + verify(statsRepository, times(1)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + @Test + fun `when called with forceRefresh, then fetches again`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + useCase(TEST_SITE_ID) + useCase( + TEST_SITE_ID, + forceRefresh = true + ) + + verify(statsRepository, times(2)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + @Test + fun `when called without token, then returns error`() = + test { + whenever(accountStore.accessToken) + .thenReturn(null) + useCase = StatsTagsUseCase( + statsRepository, + accountStore + ) + + val result = useCase(TEST_SITE_ID) + + assertThat(result).isInstanceOf( + TagsResult.Error::class.java + ) + verify(statsRepository, never()) + .fetchTags( + siteId = any(), + max = any() + ) + } + + @Test + fun `when clearCache called, then next call fetches again`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + useCase(TEST_SITE_ID) + useCase.clearCache() + useCase(TEST_SITE_ID) + + verify(statsRepository, times(2)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + @Test + fun `when errors, then cache is not populated`() = + test { + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Error("Network error") + ) + + val first = useCase(TEST_SITE_ID) + assertThat(first).isInstanceOf( + TagsResult.Error::class.java + ) + + whenever( + statsRepository.fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + ).thenReturn( + TagsResult.Success(createTestTagsData()) + ) + + val second = useCase(TEST_SITE_ID) + assertThat(second).isInstanceOf( + TagsResult.Success::class.java + ) + verify(statsRepository, times(2)) + .fetchTags( + siteId = TEST_SITE_ID, + max = DEFAULT_MAX + ) + } + + private fun createTestTagsData() = StatsTagsData( + tagGroups = emptyList() + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + private const val DEFAULT_MAX = 10 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModelTest.kt new file mode 100644 index 000000000000..6086287dfb9e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/BaseTagsAndCategoriesViewModelTest.kt @@ -0,0 +1,496 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import org.wordpress.android.ui.newstats.datasource.TagData +import org.wordpress.android.ui.newstats.datasource.TagGroupData +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.viewmodel.ResourceProvider + +/** + * Tests for [BaseTagsAndCategoriesViewModel] using + * a concrete test subclass. + */ +@ExperimentalCoroutinesApi +class BaseTagsAndCategoriesViewModelTest : + BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var statsTagsUseCase: + StatsTagsUseCase + + @Mock + private lateinit var resourceProvider: + ResourceProvider + + private val mapper = TagsAndCategoriesMapper() + + private lateinit var viewModel: + TestTagsAndCategoriesViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + } + + private fun initViewModel( + maxItems: Int = TEST_MAX_ITEMS + ) { + viewModel = TestTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper, + maxItems + ) + } + + // region Initial state + + @Test + fun `initial state is Loading`() { + initViewModel() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loading::class.java + ) + } + + // endregion + + // region loadData guard + + @Test + fun `when loadData called twice, then fetch once`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase, times(1)) + .invoke(any(), any(), any()) + } + + @Test + fun `when loadData after success, then no refetch`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase, times(1)) + .invoke(any(), any(), any()) + } + + @Test + fun `when loadData after error, then retries`() = + test { + stubApiError() + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn( + TagsResult.Error("Network error") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + + // endregion + + // region Error states + + @Test + fun `when no site, then error state`() = + test { + stubNoSiteError() + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(NO_SITE_ERROR) + } + + @Test + fun `when no site, then use case not called`() = + test { + stubNoSiteError() + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase, never()) + .invoke(any(), any(), any()) + } + + @Test + fun `when fetch returns error, then error state`() = + test { + stubApiError() + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn( + TagsResult.Error("Network error") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(API_ERROR) + } + + @Test + fun `when exception thrown, then error state`() = + test { + stubUnknownError() + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenThrow( + RuntimeException("Test exception") + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Error::class.java + ) + assertThat( + (state as TagsAndCategoriesCardUiState + .Error).message + ).isEqualTo(UNKNOWN_ERROR) + } + + // endregion + + // region Success states + + @Test + fun `when fetch succeeds, then loaded state`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when fetch succeeds, then items mapped`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items).hasSize(2) + assertThat(state.items[0].name) + .isEqualTo(TEST_CATEGORY_NAME) + assertThat(state.items[0].views) + .isEqualTo(TEST_CATEGORY_VIEWS) + } + + @Test + fun `when empty result, then loaded empty list`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = emptyList() + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items).isEmpty() + assertThat(state.maxViewsForBar) + .isEqualTo(1L) + } + + @Test + fun `maxViewsForBar equals first item views`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.maxViewsForBar) + .isEqualTo(TEST_CATEGORY_VIEWS) + } + + // endregion + + // region maxItems passed to use case + + @Test + fun `maxItems is passed to use case`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel(maxItems = 42) + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase).invoke( + eq(TEST_SITE_ID), eq(42), any() + ) + } + + // endregion + + // region resetForRefresh + + @Test + fun `resetForRefresh sets loading state`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + + viewModel.callResetForRefresh() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loading::class.java + ) + } + + @Test + fun `resetForRefresh allows refetch`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + viewModel.callResetForRefresh() + viewModel.callFetchData(forceRefresh = true) + advanceUntilIdle() + + verify(statsTagsUseCase, times(2)) + .invoke(any(), any(), any()) + } + + // endregion + + // region Helpers + + private fun stubNoSiteError() { + whenever( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn(NO_SITE_ERROR) + } + + private fun stubApiError() { + whenever( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(API_ERROR) + } + + private fun stubUnknownError() { + whenever( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn(UNKNOWN_ERROR) + } + + private fun createSuccessResult() = + TagsResult.Success( + StatsTagsData( + tagGroups = listOf( + TagGroupData( + tags = listOf( + TagData( + tagType = "category", + name = TEST_CATEGORY_NAME + ) + ), + views = TEST_CATEGORY_VIEWS + ), + TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = TEST_TAG_NAME + ) + ), + views = TEST_TAG_VIEWS + ) + ) + ) + ) + + // endregion + + /** + * Concrete subclass exposing protected methods + * for testing. + */ + private class TestTagsAndCategoriesViewModel( + selectedSiteRepository: SelectedSiteRepository, + statsTagsUseCase: StatsTagsUseCase, + resourceProvider: ResourceProvider, + mapper: TagsAndCategoriesMapper, + override val maxItems: Int + ) : BaseTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper + ) { + fun callResetForRefresh() = resetForRefresh() + fun callFetchData( + forceRefresh: Boolean = false + ) = fetchData(forceRefresh) + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_MAX_ITEMS = 10 + private const val NO_SITE_ERROR = + "No site selected" + private const val API_ERROR = + "Failed to load stats" + private const val UNKNOWN_ERROR = + "An unknown error occurred" + private const val TEST_CATEGORY_NAME = + "Uncategorized" + private const val TEST_CATEGORY_VIEWS = 83L + private const val TEST_TAG_NAME = "snaps" + private const val TEST_TAG_VIEWS = 15L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModelTest.kt new file mode 100644 index 000000000000..af222bb6e138 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailViewModelTest.kt @@ -0,0 +1,120 @@ +package org.wordpress.android.ui.newstats.tagsandcategories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.datasource.StatsTagsData +import org.wordpress.android.ui.newstats.datasource.TagData +import org.wordpress.android.ui.newstats.datasource.TagGroupData +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.ui.newstats.repository.TagsResult +import org.wordpress.android.viewmodel.ResourceProvider + +/** + * Tests specific to [TagsAndCategoriesDetailViewModel]. + * Base ViewModel behaviour (loading, errors, guards) is + * covered by [BaseTagsAndCategoriesViewModelTest]. + */ +@ExperimentalCoroutinesApi +class TagsAndCategoriesDetailViewModelTest : + BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var statsTagsUseCase: + StatsTagsUseCase + + @Mock + private lateinit var resourceProvider: + ResourceProvider + + private val mapper = TagsAndCategoriesMapper() + + private lateinit var viewModel: + TagsAndCategoriesDetailViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + } + + private fun initViewModel() { + viewModel = TagsAndCategoriesDetailViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper + ) + } + + @Test + fun `initial state is Loading`() { + initViewModel() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loading::class.java + ) + } + + @Test + fun `when loadData, then fetches with detail max`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase) + .invoke( + eq(TEST_SITE_ID), + eq(DETAIL_MAX_ITEMS), + any() + ) + } + + private fun createSuccessResult() = + TagsResult.Success( + StatsTagsData( + tagGroups = listOf( + TagGroupData( + tags = listOf( + TagData( + tagType = "category", + name = "Uncategorized" + ) + ), + views = 83L + ) + ) + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val DETAIL_MAX_ITEMS = 100 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt index 6c23d13b06f5..17dd50a6490a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt @@ -13,12 +13,11 @@ import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.datasource.StatsTagsData import org.wordpress.android.ui.newstats.datasource.TagData import org.wordpress.android.ui.newstats.datasource.TagGroupData -import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.StatsTagsUseCase import org.wordpress.android.ui.newstats.repository.TagsResult import org.wordpress.android.viewmodel.ResourceProvider @@ -29,14 +28,13 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { SelectedSiteRepository @Mock - private lateinit var accountStore: AccountStore - - @Mock - private lateinit var statsRepository: StatsRepository + private lateinit var statsTagsUseCase: StatsTagsUseCase @Mock private lateinit var resourceProvider: ResourceProvider + private val mapper = TagsAndCategoriesMapper() + private lateinit var viewModel: TagsAndCategoriesViewModel @@ -51,8 +49,6 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { whenever( selectedSiteRepository.getSelectedSite() ).thenReturn(testSite) - whenever(accountStore.accessToken) - .thenReturn(TEST_ACCESS_TOKEN) } private fun stubNoSiteError() { @@ -71,12 +67,20 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ).thenReturn(API_ERROR) } + private fun stubUnknownError() { + whenever( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn(UNKNOWN_ERROR) + } + private fun initViewModel() { viewModel = TagsAndCategoriesViewModel( selectedSiteRepository, - accountStore, - statsRepository, - resourceProvider + statsTagsUseCase, + resourceProvider, + mapper ) } @@ -117,56 +121,12 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ).isEqualTo(NO_SITE_ERROR) } - @Test - fun `when access token is null, then error state`() = - test { - stubApiError() - whenever(accountStore.accessToken) - .thenReturn(null) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - TagsAndCategoriesCardUiState - .Error::class.java - ) - assertThat( - (state as TagsAndCategoriesCardUiState - .Error).message - ).isEqualTo(API_ERROR) - } - - @Test - fun `when access token is empty, then error state`() = - test { - stubApiError() - whenever(accountStore.accessToken) - .thenReturn("") - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertThat(state).isInstanceOf( - TagsAndCategoriesCardUiState - .Error::class.java - ) - assertThat( - (state as TagsAndCategoriesCardUiState - .Error).message - ).isEqualTo(API_ERROR) - } - @Test fun `when fetch returns error, then error state`() = test { stubApiError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Error("Network error") ) @@ -183,10 +143,11 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { } @Test - fun `when exception is thrown, then error state with message`() = + fun `when exception is thrown, then error state with localized message`() = test { + stubUnknownError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenThrow( RuntimeException("Test exception") ) @@ -203,7 +164,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { assertThat( (state as TagsAndCategoriesCardUiState .Error).message - ).isEqualTo("Test exception") + ).isEqualTo(UNKNOWN_ERROR) } // endregion @@ -212,7 +173,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when fetch succeeds, then loaded state`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -230,7 +191,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when fetch succeeds, then items are mapped correctly`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -254,7 +215,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when fetch succeeds, then maxViewsForBar is first item views`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -267,37 +228,6 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { .isEqualTo(TEST_CATEGORY_VIEWS) } - @Test - fun `when more than 7 items, then card shows only 7`() = - test { - val manyGroups = (1..10).map { i -> - TagGroupData( - tags = listOf( - TagData( - tagType = "tag", - name = "Tag $i" - ) - ), - views = (100 - i).toLong() - ) - } - whenever( - statsRepository.fetchTags(any(), any()) - ).thenReturn( - TagsResult.Success( - StatsTagsData(tagGroups = manyGroups) - ) - ) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val state = viewModel.uiState.value - as TagsAndCategoriesCardUiState.Loaded - assertThat(state.items).hasSize(CARD_MAX_ITEMS) - } - @Test fun `when multi-tag group, then name is joined with separator`() = test { @@ -315,7 +245,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -352,7 +282,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -384,7 +314,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -420,7 +350,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { views = 50 ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -443,7 +373,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when empty result, then loaded with empty list`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Success( StatsTagsData( @@ -459,7 +389,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value as TagsAndCategoriesCardUiState.Loaded assertThat(state.items).isEmpty() - assertThat(state.maxViewsForBar).isEqualTo(1L) + assertThat(state.maxViewsForBar) + .isEqualTo(1L) } // endregion @@ -468,7 +399,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when loadData called twice, then fetch only once`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -477,8 +408,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { viewModel.loadData() advanceUntilIdle() - verify(statsRepository, times(1)) - .fetchTags(eq(TEST_SITE_ID), any()) + verify(statsTagsUseCase, times(1)) + .invoke(eq(TEST_SITE_ID), any(), any()) } // endregion @@ -487,7 +418,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { fun `when refresh, then data is re-fetched`() = test { whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -496,8 +427,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { viewModel.refresh() advanceUntilIdle() - verify(statsRepository, times(2)) - .fetchTags(eq(TEST_SITE_ID), any()) + verify(statsTagsUseCase, times(2)) + .invoke(eq(TEST_SITE_ID), any(), any()) } @Test @@ -505,7 +436,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { test { stubApiError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Error("Network error") ) @@ -521,7 +452,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) viewModel.refresh() @@ -537,11 +468,11 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { // region refresh sets loading @Test - fun `when refresh with no site, then loading then error`() = + fun `when refresh with no site, then error`() = test { stubNoSiteError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) initViewModel() @@ -568,7 +499,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { test { stubApiError() whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn( TagsResult.Error("Network error") ) @@ -584,7 +515,7 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { ) whenever( - statsRepository.fetchTags(any(), any()) + statsTagsUseCase(any(), any(), any()) ).thenReturn(createSuccessResult()) viewModel.loadData() @@ -598,65 +529,6 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { } // endregion - // region getDetailData - @Test - fun `when getDetailData, then returns all items`() = - test { - val manyGroups = (1..10).map { i -> - TagGroupData( - tags = listOf( - TagData( - tagType = "tag", - name = "Tag $i" - ) - ), - views = (100 - i).toLong() - ) - } - whenever( - statsRepository.fetchTags(any(), any()) - ).thenReturn( - TagsResult.Success( - StatsTagsData(tagGroups = manyGroups) - ) - ) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - val detailData = viewModel.getDetailData() - assertThat(detailData).hasSize(10) - } - - @Test - fun `when getDetailData before load, then empty list`() { - initViewModel() - - val detailData = viewModel.getDetailData() - assertThat(detailData).isEmpty() - } - // endregion - - // region Repository interaction - @Test - fun `when loadData, then init and fetchTags called`() = - test { - whenever( - statsRepository.fetchTags(any(), any()) - ).thenReturn(createSuccessResult()) - - initViewModel() - viewModel.loadData() - advanceUntilIdle() - - verify(statsRepository) - .init(TEST_ACCESS_TOKEN) - verify(statsRepository) - .fetchTags(eq(TEST_SITE_ID), any()) - } - // endregion - private fun createSuccessResult() = TagsResult.Success( StatsTagsData( @@ -685,19 +557,17 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L - private const val TEST_ACCESS_TOKEN = - "test_access_token" private const val NO_SITE_ERROR = "No site selected" private const val API_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = + "An unknown error occurred" private const val TEST_CATEGORY_NAME = "Uncategorized" private const val TEST_CATEGORY_VIEWS = 83L private const val TEST_TAG_NAME = "snaps" private const val TEST_TAG_VIEWS = 15L - - private const val CARD_MAX_ITEMS = 7 } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4a7e4070f6c..1eda59b10a98 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 = 'trunk-502e9561f2a68294f0065867bab9214cc9a6b78c' +wordpress-rs = 'trunk-262a778ead5f163f3450d62adfac21fb32048714' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From c0fbda7ed77b3b55f36f670f5ece116b05eb17fd Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 19 Mar 2026 14:42:30 +0100 Subject: [PATCH 14/14] Simplify: fix StatsTagsUseCase error handling, deduplicate bottom sheets - Fix critical bug in StatsTagsUseCase where an exception from fetchTags() would leave the CompletableDeferred incomplete, hanging all awaiters - Extract generic AddCardBottomSheet to replace three duplicate implementations (Stats, Insights, Subscribers) - Merge duplicate LaunchedEffect(cardsToLoad) blocks in InsightsTabContent Co-Authored-By: Claude Opus 4.6 (1M context) --- .../android/ui/newstats/NewStatsActivity.kt | 95 ++-------------- .../components/AddStatsCardBottomSheet.kt | 70 +++++++++--- .../newstats/repository/StatsTagsUseCase.kt | 26 +++-- .../AddSubscribersCardBottomSheet.kt | 102 ++---------------- 4 files changed, 88 insertions(+), 205 deletions(-) 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 dd7f0f498369..b17e628c5147 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 @@ -38,8 +38,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -55,7 +53,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -67,6 +64,7 @@ import org.wordpress.android.R import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.ui.newstats.components.AddCardBottomSheet import org.wordpress.android.ui.newstats.components.AddStatsCardBottomSheet import org.wordpress.android.ui.newstats.components.CardPosition import org.wordpress.android.ui.newstats.components.NoConnectionContent @@ -940,6 +938,11 @@ private fun InsightsTabContent( LaunchedEffect(cardsToLoad) { insightsViewModel.loadDataIfNeeded() + if (InsightsCardType.TAGS_AND_CATEGORIES + in cardsToLoad + ) { + tagsAndCategoriesViewModel.loadData() + } } val onRetryData = remember { @@ -960,18 +963,13 @@ private fun InsightsTabContent( } } - LaunchedEffect(cardsToLoad) { - if (InsightsCardType.TAGS_AND_CATEGORIES - in cardsToLoad - ) { - tagsAndCategoriesViewModel.loadData() - } - } - if (showAddCardSheet) { - AddInsightsCardBottomSheet( + AddCardBottomSheet( sheetState = addCardSheetState, availableCards = hiddenCards, + getDisplayNameResId = { + it.displayNameResId + }, onDismiss = { showAddCardSheet = false }, onCardSelected = { cardType -> insightsViewModel.addCard(cardType) @@ -1277,79 +1275,6 @@ private fun InsightsTabContent( } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AddInsightsCardBottomSheet( - sheetState: SheetState, - availableCards: List, - onDismiss: () -> Unit, - onCardSelected: (InsightsCardType) -> Unit -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - ) { - Text( - text = stringResource(R.string.stats_add_card_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - - if (availableCards.isEmpty()) { - Text( - text = stringResource( - R.string.stats_all_cards_visible - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme - .onSurfaceVariant, - modifier = Modifier.padding(vertical = 24.dp) - ) - } else { - availableCards.forEach { cardType -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onCardSelected(cardType) - onDismiss() - } - .padding(vertical = 12.dp), - verticalAlignment = - Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme - .colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = stringResource( - cardType.displayNameResId - ), - style = MaterialTheme - .typography.bodyLarge, - color = MaterialTheme - .colorScheme.onSurface - ) - } - } - } - } - } -} - @Composable private fun StatsPeriodMenu( expanded: Boolean, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/AddStatsCardBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/AddStatsCardBottomSheet.kt index 9e056ee5e288..bea308276ef9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/AddStatsCardBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/AddStatsCardBottomSheet.kt @@ -26,21 +26,28 @@ import org.wordpress.android.R import org.wordpress.android.ui.newstats.StatsCardType /** - * Bottom sheet for adding stats cards. - * Shows a list of available (hidden) cards that can be added. + * Generic bottom sheet for adding stats cards. + * Shows a list of available (hidden) cards that + * can be added. * * @param sheetState The state of the bottom sheet - * @param availableCards List of card types that can be added (currently hidden) - * @param onDismiss Callback invoked when the sheet is dismissed - * @param onCardSelected Callback invoked when a card is selected to be added + * @param availableCards List of card types that can + * be added (currently hidden) + * @param getDisplayNameResId Resolves each card to + * its display name string resource + * @param onDismiss Callback invoked when the sheet + * is dismissed + * @param onCardSelected Callback invoked when a card + * is selected to be added */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AddStatsCardBottomSheet( +fun AddCardBottomSheet( sheetState: SheetState, - availableCards: List, + availableCards: List, + getDisplayNameResId: (T) -> Int, onDismiss: () -> Unit, - onCardSelected: (StatsCardType) -> Unit + onCardSelected: (T) -> Unit ) { ModalBottomSheet( onDismissRequest = onDismiss, @@ -53,23 +60,34 @@ fun AddStatsCardBottomSheet( .padding(bottom = 32.dp) ) { Text( - text = stringResource(R.string.stats_add_card_title), - style = MaterialTheme.typography.titleLarge, + text = stringResource( + R.string.stats_add_card_title + ), + style = MaterialTheme + .typography.titleLarge, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier + .padding(bottom = 16.dp) ) if (availableCards.isEmpty()) { Text( - text = stringResource(R.string.stats_all_cards_visible), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 24.dp) + text = stringResource( + R.string.stats_all_cards_visible + ), + style = MaterialTheme + .typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(vertical = 24.dp) ) } else { availableCards.forEach { cardType -> AddCardItem( - label = stringResource(cardType.displayNameResId), + label = stringResource( + getDisplayNameResId(cardType) + ), onClick = { onCardSelected(cardType) onDismiss() @@ -81,6 +99,26 @@ fun AddStatsCardBottomSheet( } } +/** + * Convenience overload for [StatsCardType]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddStatsCardBottomSheet( + sheetState: SheetState, + availableCards: List, + onDismiss: () -> Unit, + onCardSelected: (StatsCardType) -> Unit +) { + AddCardBottomSheet( + sheetState = sheetState, + availableCards = availableCards, + getDisplayNameResId = { it.displayNameResId }, + onDismiss = onDismiss, + onCardSelected = onCardSelected + ) +} + @Composable private fun AddCardItem( label: String, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt index 5dc0b868e2e2..1a973a3d34c5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt @@ -67,18 +67,24 @@ class StatsTagsUseCase @Inject constructor( } if (isOwner) { - val result = statsRepository.fetchTags( - siteId = siteId, - max = max - ) - mutex.withLock { - if (result is TagsResult.Success) { - cachedTags = - Triple(siteId, max, result.data) + try { + val result = statsRepository.fetchTags( + siteId = siteId, + max = max + ) + mutex.withLock { + if (result is TagsResult.Success) { + cachedTags = + Triple(siteId, max, result.data) + } + inFlight = null } - inFlight = null + deferred.complete(result) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + mutex.withLock { inFlight = null } + deferred.completeExceptionally(e) + throw e } - deferred.complete(result) } return deferred.await() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt index 5380558f222a..3b466c65eb66 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt @@ -1,28 +1,9 @@ package org.wordpress.android.ui.newstats.subscribers -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.wordpress.android.R +import org.wordpress.android.ui.newstats.components.AddCardBottomSheet @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -32,78 +13,11 @@ fun AddSubscribersCardBottomSheet( onDismiss: () -> Unit, onCardSelected: (SubscribersCardType) -> Unit ) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - ) { - Text( - text = stringResource( - R.string.stats_add_card_title - ), - style = MaterialTheme - .typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(bottom = 16.dp) - ) - - if (availableCards.isEmpty()) { - Text( - text = stringResource( - R.string.stats_all_cards_visible - ), - style = MaterialTheme - .typography.bodyMedium, - color = MaterialTheme - .colorScheme.onSurfaceVariant, - modifier = Modifier - .padding(vertical = 24.dp) - ) - } else { - availableCards.forEach { cardType -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onCardSelected(cardType) - onDismiss() - } - .padding(vertical = 12.dp), - verticalAlignment = - Alignment.CenterVertically - ) { - Icon( - imageVector = - Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme - .colorScheme.primary, - modifier = Modifier - .size(24.dp) - ) - Spacer( - modifier = Modifier - .width(16.dp) - ) - Text( - text = stringResource( - cardType - .displayNameResId - ), - style = MaterialTheme - .typography.bodyLarge, - color = MaterialTheme - .colorScheme.onSurface - ) - } - } - } - } - } + AddCardBottomSheet( + sheetState = sheetState, + availableCards = availableCards, + getDisplayNameResId = { it.displayNameResId }, + onDismiss = onDismiss, + onCardSelected = onCardSelected + ) }