diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index fe2fb57f9661..782453f978f5 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -127,6 +127,16 @@ android:theme="@style/WordPress.NoActionBar" android:exported="false" /> + + + + = + listOf( + YEAR_IN_REVIEW, + ALL_TIME_STATS, + MOST_POPULAR_DAY, + MOST_POPULAR_TIME, + TAGS_AND_CATEGORIES + ) + } +} 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..7f40cecd33d7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsCardsConfiguration.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.ui.newstats + +data class InsightsCardsConfiguration( + val visibleCards: List = + InsightsCardType.defaultCards(), + val hiddenCards: List = emptyList() +) { + fun computeHiddenCards(): 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..136bd30e415f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/InsightsViewModel.kt @@ -0,0 +1,353 @@ +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 +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.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 + +@HiltViewModel +class InsightsViewModel @Inject constructor( + private val selectedSiteRepository: + SelectedSiteRepository, + private val cardConfigurationRepository: + InsightsCardsConfigurationRepository, + private val networkUtilsWrapper: NetworkUtilsWrapper, + private val statsSummaryUseCase: StatsSummaryUseCase, + private val statsInsightsUseCase: StatsInsightsUseCase, + private val statsTagsUseCase: StatsTagsUseCase +) : 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() + + // 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() + + 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() + } + + fun checkNetworkStatus(): Boolean { + val isAvailable = + networkUtilsWrapper.isNetworkAvailable() + _isNetworkAvailable.value = isAvailable + return isAvailable + } + + // region Data fetching + + fun loadDataIfNeeded() { + if (isDataLoaded.get() || + !isDataLoading.compareAndSet(false, true) + ) return + fetchData() + } + + fun fetchData(forceRefresh: Boolean = false) { + val siteId = resolvedSiteId() ?: run { + isDataLoading.set(false) + _isDataRefreshing.value = false + return + } + 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 { + if (shouldFetchSummary) { + launch { + fetchSummary(siteId, forceRefresh) + } + } + if (shouldFetchInsights) { + launch { + fetchInsights( + siteId, forceRefresh + ) + } + } + } + isDataLoaded.set(true) + } finally { + isDataLoading.set(false) + _isDataRefreshing.value = false + } + } + } + + @Suppress( + "TooGenericExceptionCaught", + "InstanceOfCheckForException" + ) + private suspend fun fetchSummary( + siteId: Long, + forceRefresh: Boolean + ) { + try { + 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 + 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 + ) + if (result is InsightsResult.Success) { + insightsFetched.set(true) + } + _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() { + fetchJob?.cancel() + isDataLoaded.set(false) + summaryFetched.set(false) + insightsFetched.set(false) + isDataLoading.set(true) + _isDataRefreshing.value = true + fetchData(forceRefresh = true) + } + + // endregion + + // region Card configuration + + private fun loadConfiguration() { + 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) + updateFromConfiguration(config) + } + } + + private fun observeConfigurationChanges() { + viewModelScope.launch { + cardConfigurationRepository.configurationFlow + .collect { pair -> + val currentSiteId = + resolvedSiteId() ?: return@collect + if (pair != null && + pair.first == currentSiteId + ) { + updateFromConfiguration(pair.second) + } + } + } + } + + private fun updateFromConfiguration( + config: InsightsCardsConfiguration + ) { + _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) { + val currentSiteId = resolvedSiteId() ?: return + viewModelScope.launch { + cardConfigurationRepository + .removeCard(currentSiteId, cardType) + } + } + + fun addCard(cardType: InsightsCardType) { + val currentSiteId = resolvedSiteId() ?: return + viewModelScope.launch { + cardConfigurationRepository + .addCard(currentSiteId, cardType) + } + } + + fun moveCardUp(cardType: InsightsCardType) { + val currentSiteId = resolvedSiteId() ?: return + viewModelScope.launch { + cardConfigurationRepository + .moveCardUp(currentSiteId, cardType) + } + } + + fun moveCardToTop(cardType: InsightsCardType) { + val currentSiteId = resolvedSiteId() ?: return + viewModelScope.launch { + cardConfigurationRepository + .moveCardToTop(currentSiteId, cardType) + } + } + + fun moveCardDown(cardType: InsightsCardType) { + val currentSiteId = resolvedSiteId() ?: return + viewModelScope.launch { + cardConfigurationRepository + .moveCardDown(currentSiteId, cardType) + } + } + + fun moveCardToBottom(cardType: InsightsCardType) { + val currentSiteId = resolvedSiteId() ?: return + viewModelScope.launch { + cardConfigurationRepository + .moveCardToBottom(currentSiteId, cardType) + } + } + + // endregion + + private fun resolvedSiteId(): Long? { + return selectedSiteRepository + .getSelectedSite()?.siteId ?: run { + AppLog.w( + AppLog.T.STATS, + "No site selected for card operation" + ) + 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 e983787b8ba9..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 @@ -64,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 @@ -92,6 +93,18 @@ import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsViewModel import org.wordpress.android.ui.newstats.subscribers.SubscribersTabContent 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.mostpopulartime.MostPopularTimeCard +import org.wordpress.android.ui.newstats.mostpopulartime.MostPopularTimeViewModel +import org.wordpress.android.ui.newstats.yearinreview.YearInReviewCard +import org.wordpress.android.ui.newstats.tagsandcategories.TagsAndCategoriesCard +import org.wordpress.android.ui.newstats.tagsandcategories.TagsAndCategoriesDetailActivity +import org.wordpress.android.ui.newstats.tagsandcategories.TagsAndCategoriesViewModel +import org.wordpress.android.ui.newstats.yearinreview.YearInReviewDetailActivity +import org.wordpress.android.ui.newstats.yearinreview.YearInReviewViewModel import org.wordpress.android.util.AppLog @AndroidEntryPoint @@ -254,11 +267,16 @@ 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() StatsTab.SUBSCRIBERS -> SubscribersTabContent() - else -> PlaceholderTabContent(tab) } } @@ -873,14 +891,387 @@ private fun List.dispatchToVisibleCards( if (StatsCardType.DEVICES in this) onDevices() } - +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun PlaceholderTabContent(tab: StatsTab) { - Box( +@Suppress("LongMethod", "LongParameterList") +private fun InsightsTabContent( + yearInReviewViewModel: YearInReviewViewModel = + viewModel(), + allTimeStatsViewModel: AllTimeStatsViewModel = + viewModel(), + mostPopularDayViewModel: MostPopularDayViewModel = + viewModel(), + mostPopularTimeViewModel: MostPopularTimeViewModel = + viewModel(), + tagsAndCategoriesViewModel: + TagsAndCategoriesViewModel = viewModel(), + insightsViewModel: InsightsViewModel = viewModel() +) { + val context = LocalContext.current + val yearInReviewUiState by yearInReviewViewModel + .uiState.collectAsState() + val allTimeStatsUiState by allTimeStatsViewModel + .uiState.collectAsState() + val mostPopularDayUiState by + mostPopularDayViewModel + .uiState.collectAsState() + val mostPopularTimeUiState by + mostPopularTimeViewModel + .uiState.collectAsState() + val tagsAndCategoriesUiState by + tagsAndCategoriesViewModel + .uiState.collectAsState() + val isRefreshing by insightsViewModel + .isDataRefreshing.collectAsState() + 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) { + insightsViewModel.loadDataIfNeeded() + if (InsightsCardType.TAGS_AND_CATEGORIES + in cardsToLoad + ) { + tagsAndCategoriesViewModel.loadData() + } + } + + 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) { + AddCardBottomSheet( + sheetState = addCardSheetState, + availableCards = hiddenCards, + getDisplayNameResId = { + it.displayNameResId + }, + onDismiss = { showAddCardSheet = false }, + onCardSelected = { cardType -> + insightsViewModel.addCard(cardType) + } + ) + } + + var showNoConnectionScreen by remember { + mutableStateOf(!isNetworkAvailable) + } + + LaunchedEffect(isNetworkAvailable) { + if (isNetworkAvailable && showNoConnectionScreen) { + showNoConnectionScreen = false + insightsViewModel.fetchData() + } else if (!isNetworkAvailable && + !showNoConnectionScreen + ) { + showNoConnectionScreen = true + } + } + + if (showNoConnectionScreen) { + NoConnectionContent( + onRetry = { + val isAvailable = + insightsViewModel.checkNetworkStatus() + if (isAvailable) { + showNoConnectionScreen = false + insightsViewModel.fetchData() + } + } + ) + return + } + + PullToRefreshBox( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + isRefreshing = isRefreshing, + state = pullToRefreshState, + onRefresh = { + insightsViewModel.checkNetworkStatus() + insightsViewModel.refreshData() + if (InsightsCardType.TAGS_AND_CATEGORIES + in visibleCards + ) { + tagsAndCategoriesViewModel.refresh() + } + }, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopCenter) + ) + } ) { - Text(text = "${stringResource(id = tab.titleResId)} - Coming Soon") + 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.ALL_TIME_STATS -> + AllTimeStatsCard( + uiState = allTimeStatsUiState, + onRemoveCard = { + insightsViewModel + .removeCard(cardType) + }, + onRetry = { + allTimeStatsViewModel + .showLoading() + onRetryData() + }, + 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 + .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, + onMoveUp = { + insightsViewModel + .moveCardUp( + cardType + ) + }, + onMoveToTop = { + insightsViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + insightsViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + insightsViewModel + .moveCardToBottom( + cardType + ) + } + ) + InsightsCardType.YEAR_IN_REVIEW -> + YearInReviewCard( + uiState = yearInReviewUiState, + onRemoveCard = { + insightsViewModel + .removeCard(cardType) + }, + onShowAllClick = { + val years = + yearInReviewViewModel + .getDetailData() + YearInReviewDetailActivity + .start(context, years) + }, + onRetry = { + yearInReviewViewModel + .showLoading() + onRetryData() + }, + cardPosition = cardPosition, + onMoveUp = { + insightsViewModel + .moveCardUp(cardType) + }, + onMoveToTop = { + insightsViewModel + .moveCardToTop(cardType) + }, + onMoveDown = { + insightsViewModel + .moveCardDown(cardType) + }, + onMoveToBottom = { + insightsViewModel + .moveCardToBottom(cardType) + } + ) + InsightsCardType + .TAGS_AND_CATEGORIES -> + TagsAndCategoriesCard( + uiState = + tagsAndCategoriesUiState, + onShowAllClick = { + TagsAndCategoriesDetailActivity + .start(context) + }, + 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 + ) + } + ) + } + } + + AddCardButton( + onClick = { showAddCardSheet = true }, + modifier = Modifier.padding(16.dp) + ) + } } } 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..c838bd176565 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModel.kt @@ -0,0 +1,46 @@ +package org.wordpress.android.ui.newstats.alltimestats + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +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.repository.StatsSummaryResult +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +@HiltViewModel +class AllTimeStatsViewModel @Inject constructor( + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + AllTimeStatsCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + 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 + ) + is StatsSummaryResult.Error -> + AllTimeStatsCardUiState.Error( + message = resourceProvider + .getString( + R.string.stats_error_api + ) + ) + } + } + + fun showLoading() { + _uiState.value = AllTimeStatsCardUiState.Loading + } +} 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/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 1e9ec4b56925..01acb6e56302 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 @@ -208,6 +208,38 @@ interface StatsDataSource { max: Int = 10 ): DevicesDataResult + /** + * Fetches stats insights for a specific site. + * + * @param siteId The WordPress.com site ID + * @return Result containing the insights data or an error + */ + 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 + + /** + * 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 + /** * Fetches subscriber count stats for a specific site. * @@ -572,6 +604,102 @@ sealed class DevicesDataResult { */ 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 highestHour: Int, + val highestHourPercent: Double, + val highestDayOfWeek: Int, + val highestDayPercent: Double, + 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 +) + +/** + * 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 +) + +/** + * 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 +) + /** * Result wrapper for stats subscribers fetch operation. */ 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 a4422c31bf69..340809b0fec2 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,8 @@ 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.StatsTagsParams import uniffi.wp_api.StatsSearchTermsParams import uniffi.wp_api.StatsSearchTermsPeriod import uniffi.wp_api.StatsTopAuthorsParams @@ -994,6 +996,191 @@ class StatsDataSourceImpl @Inject constructor( } } + @Suppress("LongMethod") + override suspend fun fetchStatsInsights( + siteId: Long + ): StatsInsightsDataResult { + val result = getOrCreateClient() + .request { requestBuilder -> + requestBuilder.statsInsights() + .getStatsInsights( + wpComSiteId = siteId.toULong(), + params = StatsInsightsParams( + locale = wpComLanguage + ) + ) + } + + 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( + highestHour = + data.highestHour.toInt(), + highestHourPercent = + data.highestHourPercent, + highestDayOfWeek = + data.highestDayOfWeek + .toInt(), + highestDayPercent = + data.highestDayPercent, + 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) + } + } + } + + 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) + } + } + } + + 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) + } + } + } + override suspend fun fetchStatsSubscribers( siteId: Long, quantity: Int, 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..07876e376ceb --- /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_views_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..675a044b9fb5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModel.kt @@ -0,0 +1,110 @@ +package org.wordpress.android.ui.newstats.mostpopularday + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +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.StatsSummaryData +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +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 resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + MostPopularDayCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + 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_api + ) + ) + } + } + + fun showLoading() { + _uiState.value = MostPopularDayCardUiState.Loading + } + + 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/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/InsightsCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt new file mode 100644 index 000000000000..d3530babe389 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepository.kt @@ -0,0 +1,299 @@ +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.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 +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 mutex = Mutex() + + private val gson = GsonBuilder() + .registerTypeAdapterFactory( + EnumWithFallbackValueTypeAdapterFactory() + ) + .create() + + private val _configurationFlow = + MutableStateFlow< + Pair? + >(null) + val configurationFlow: + StateFlow< + Pair? + > = _configurationFlow.asStateFlow() + + suspend fun getConfiguration( + siteId: Long + ): InsightsCardsConfiguration = + withContext(ioDispatcher) { + mutex.withLock { + loadAndMigrate(siteId) + } + } + + private fun persistConfiguration( + siteId: Long, + configuration: InsightsCardsConfiguration + ) { + appPrefsWrapper + .setStatsInsightsCardsConfigurationJson( + siteId, + gson.toJson(configuration) + ) + _configurationFlow.value = + siteId to configuration + } + + suspend fun removeCard( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + mutex.withLock { + val current = loadAndMigrate(siteId) + val newVisibleCards = + current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + val newHiddenCards = + (current.hiddenCards + cardType) + .distinct() + persistConfiguration( + siteId, + current.copy( + visibleCards = newVisibleCards, + hiddenCards = newHiddenCards + ) + ) + } + } + + suspend fun addCard( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + mutex.withLock { + 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, + hiddenCards = newHiddenCards + ) + ) + } + } + + suspend fun moveCardUp( + siteId: Long, + cardType: InsightsCardType + ): Unit = withContext(ioDispatcher) { + mutex.withLock { + val current = loadAndMigrate(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) { + mutex.withLock { + val current = loadAndMigrate(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) { + mutex.withLock { + val current = loadAndMigrate(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) { + mutex.withLock { + val current = loadAndMigrate(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index >= 0 && + index < current.visibleCards.size - 1 + ) { + moveCardToIndex( + siteId, + current, + cardType, + current.visibleCards.size - 1 + ) + } + } + } + + private fun moveCardToIndex( + siteId: Long, + current: InsightsCardsConfiguration, + cardType: InsightsCardType, + newIndex: Int + ) { + val newVisibleCards = + current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + newVisibleCards.add(newIndex, cardType) + persistConfiguration( + siteId, + 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 + ) + 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) + } + } + + /** + * 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 + ): Boolean { + return (config.visibleCards as List) + .none { it == null } + } + + private fun resetToDefault( + siteId: Long + ): InsightsCardsConfiguration { + val defaultConfig = + InsightsCardsConfiguration() + persistConfiguration(siteId, defaultConfig) + return defaultConfig + } +} 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..b1b701be8617 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCase.kt @@ -0,0 +1,51 @@ +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 + } + } + + suspend fun clearCache() { + mutex.withLock { cachedInsights = null } + } +} 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 e92aa0e54526..8ba642689dbb 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,12 @@ 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.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 import org.wordpress.android.ui.newstats.datasource.StatsUnit import org.wordpress.android.ui.newstats.datasource.StatsVisitsData @@ -84,7 +90,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) } @@ -1327,6 +1332,82 @@ class StatsRepository @Inject constructor( } } + suspend fun fetchInsights( + siteId: Long + ): InsightsResult = withContext(ioDispatcher) { + val result = statsDataSource.fetchStatsInsights( + siteId = siteId + ) + when (result) { + is StatsInsightsDataResult.Success -> + InsightsResult.Success( + data = result.data + ) + is StatsInsightsDataResult.Error -> { + appLogWrapper.e( + AppLog.T.STATS, + "Error fetching insights: " + + "${result.errorType}" + ) + InsightsResult.Error( + result.errorType.name + ) + } + } + } + + 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 + ) + } + } + } + + 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 + ) + } + } + } + /** * Fetches all-time subscriber counts: current, 30d ago, * 60d ago, 90d ago. Makes 4 parallel API calls. @@ -1552,6 +1633,10 @@ class StatsRepository @Inject constructor( ) } } + + companion object { + private const val DEFAULT_TAGS_MAX = 10 + } } /** @@ -1935,6 +2020,42 @@ data class DeviceItemData( val views: Double ) +/** + * Result of fetching insights data from the repository. + */ +sealed class InsightsResult { + data class Success( + val data: StatsInsightsData + ) : InsightsResult() + data class Error( + 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() +} + +/** + * 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() +} + /** * Result wrapper for subscribers all-time stats fetch operation. */ 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..42d832ab36a1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCase.kt @@ -0,0 +1,51 @@ +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 + } + } + + 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..1a973a3d34c5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsTagsUseCase.kt @@ -0,0 +1,111 @@ +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) { + try { + 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) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + mutex.withLock { inFlight = null } + deferred.completeExceptionally(e) + throw e + } + } + + 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/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 + ) } 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/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..c84ddc8cd1f8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt @@ -0,0 +1,352 @@ +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.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 +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 +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) + + viewModel.loadData() + + setContent { + AppThemeM3 { + val uiState by viewModel.uiState + .collectAsState() + TagsAndCategoriesDetailScreen( + uiState = uiState, + onBackPressed = + onBackPressedDispatcher + ::onBackPressed, + onRetry = { viewModel.loadData() } + ) + } + } + } + + companion object { + fun start(context: Context) { + val intent = Intent( + context, + TagsAndCategoriesDetailActivity::class + .java + ) + context.startActivity(intent) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TagsAndCategoriesDetailScreen( + uiState: TagsAndCategoriesCardUiState, + onBackPressed: () -> Unit, + onRetry: () -> Unit +) { + 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 -> + 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) + ) + } + } +} + +@Composable +private fun DetailLoadedContent( + items: List, + maxViews: Long, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) { + Box( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource( + R.string + .stats_insights_tags_empty + ), + style = MaterialTheme.typography + .bodyMedium, + color = MaterialTheme.colorScheme + .onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + return + } + + 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) + ) + } + } +} + +@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 + ) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TagsAndCategoriesDetailPreview() { + AppThemeM3 { + TagsAndCategoriesDetailScreen( + 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 + ) + ), + 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 new file mode 100644 index 000000000000..25f71c85b5f4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModel.kt @@ -0,0 +1,31 @@ +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 TagsAndCategoriesViewModel @Inject constructor( + selectedSiteRepository: SelectedSiteRepository, + statsTagsUseCase: StatsTagsUseCase, + resourceProvider: ResourceProvider, + mapper: TagsAndCategoriesMapper +) : BaseTagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper +) { + override val maxItems: Int = CARD_MAX_ITEMS + + fun refresh() { + resetForRefresh() + fetchData(forceRefresh = true) + } + + companion object { + private const val CARD_MAX_ITEMS = 7 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index 02baf6b635c2..17c38e036fb8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -23,6 +23,16 @@ fun formatStatValue(value: Long): String { } } +private const val FORMAT_DECIMAL = "%.1f" + +fun formatStatValue(value: Double): String { + return if (value == value.toLong().toDouble()) { + value.toLong().toString() + } else { + String.format(Locale.getDefault(), FORMAT_DECIMAL, value) + } +} + /** * Formats an email stat value for display, showing "-" for zero values. */ 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..29ab2103c0cc --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCard.kt @@ -0,0 +1,394 @@ +package org.wordpress.android.ui.newstats.yearinreview + +import androidx.annotation.StringRes +import org.wordpress.android.ui.newstats.util.rememberShimmerBrush +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 +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.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 +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.ShowAllFooter +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 +private val MiniCardCornerRadius = 8.dp + +@Composable +@Suppress("LongParameterList") +fun YearInReviewCard( + uiState: YearInReviewCardUiState, + onRemoveCard: () -> Unit, + onShowAllClick: () -> 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 YearInReviewCardUiState.Loading -> + LoadingContent() + is YearInReviewCardUiState.Loaded -> + LoadedContent( + uiState, + onRemoveCard, + onShowAllClick, + cardPosition, + onMoveUp, + onMoveToTop, + onMoveDown, + onMoveToBottom + ) + is YearInReviewCardUiState.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(140.dp) + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(16.dp)) + repeat(2) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(12.dp) + ) { + repeat(2) { + Box( + modifier = Modifier + .weight(1f) + .height(80.dp) + .clip( + RoundedCornerShape( + MiniCardCornerRadius + ) + ) + .background(shimmerBrush) + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: YearInReviewCardUiState.Loaded, + onRemoveCard: () -> Unit, + onShowAllClick: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + 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_title, + year.year + ), + 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)) + // 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_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_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) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + ShowAllFooter(onClick = onShowAllClick) + } +} + +@Composable +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) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun ErrorContent( + state: YearInReviewCardUiState.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_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 = onRetry) { + Text( + text = stringResource(R.string.retry) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun YearInReviewCardLoadingPreview() { + AppThemeM3 { + YearInReviewCard( + uiState = YearInReviewCardUiState.Loading, + onRemoveCard = {}, + onShowAllClick = {}, + onRetry = {} + ) + } +} + +@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 + ) + ) + ), + onRemoveCard = {}, + onShowAllClick = {}, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun YearInReviewCardErrorPreview() { + AppThemeM3 { + YearInReviewCard( + uiState = YearInReviewCardUiState.Error( + message = "Failed to load stats" + ), + onRemoveCard = {}, + 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 new file mode 100644 index 000000000000..fa778bba5516 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewCardUiState.kt @@ -0,0 +1,28 @@ +package org.wordpress.android.ui.newstats.yearinreview + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class YearInReviewCardUiState { + data object Loading : YearInReviewCardUiState() + + data class Loaded( + val years: List + ) : YearInReviewCardUiState() + + data class Error( + val message: String + ) : YearInReviewCardUiState() +} + +@Parcelize +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 +) : 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..c1f8ca8e3ada --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewDetailActivity.kt @@ -0,0 +1,269 @@ +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) + ) { + 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_total_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_avg_comments_per_post, + value = formatStatValue(year.avgComments) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_total_likes, + value = formatStatValue(year.totalLikes) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_avg_likes_per_post, + value = formatStatValue(year.avgLikes) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_total_words, + value = formatStatValue(year.totalWords) + ) + StatDivider() + StatRow( + labelRes = + R.string.stats_insights_avg_words_per_post, + 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 new file mode 100644 index 000000000000..09986360a7f4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModel.kt @@ -0,0 +1,94 @@ +package org.wordpress.android.ui.newstats.yearinreview + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +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.YearInsightsData +import org.wordpress.android.ui.newstats.repository.InsightsResult +import org.wordpress.android.viewmodel.ResourceProvider +import java.time.Year +import javax.inject.Inject + +@HiltViewModel +class YearInReviewViewModel @Inject constructor( + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + YearInReviewCardUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + 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 + ) + } + is InsightsResult.Error -> + YearInReviewCardUiState.Error( + message = resourceProvider + .getString( + R.string.stats_error_api + ) + ) + } + } + + fun showLoading() { + _uiState.value = YearInReviewCardUiState.Loading + } + + fun getDetailData(): List { + val state = _uiState.value + return if (state is YearInReviewCardUiState.Loaded) { + state.years + } else { + emptyList() + } + } + + companion object { + private fun YearInsightsData.toUiModel() = + YearSummary( + year = year, + totalPosts = totalPosts, + totalWords = totalWords, + avgWords = avgWords, + totalLikes = totalLikes, + avgLikes = avgLikes, + 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/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 5fd58aaf14ed..4650388ee373 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, SUBSCRIBERS_CARDS_CONFIGURATION_JSON, } @@ -1793,6 +1794,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; + } + @Nullable public static String getSubscribersCardsConfigurationJson(long siteId) { return prefs().getString(getSubscribersCardsConfigurationKey(siteId), null); 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 7b4669210cf8..54ddf9de26db 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 getSubscribersCardsConfigurationJson(siteId: Long): String? = AppPrefs.getSubscribersCardsConfigurationJson(siteId) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index b3c306e9f692..6b3781a272e2 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1521,15 +1521,33 @@ %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 Best Views Ever Today + Year in Review + %s in review All-time + All-time stats + Most popular day + Day + %1$s%% of views + Most popular time Tags and Categories + No tags or categories found + 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 new file mode 100644 index 000000000000..00bf911b2b8d --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsCardsConfigurationTest.kt @@ -0,0 +1,77 @@ +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.computeHiddenCards() + + assertThat(hiddenCards).containsExactlyInAnyOrder( + InsightsCardType.ALL_TIME_STATS, + InsightsCardType.MOST_POPULAR_DAY, + InsightsCardType.MOST_POPULAR_TIME, + InsightsCardType.YEAR_IN_REVIEW, + InsightsCardType.TAGS_AND_CATEGORIES + ) + } + + @Test + fun `when all cards visible, then hiddenCards returns empty list`() { + val config = InsightsCardsConfiguration( + visibleCards = InsightsCardType.entries.toList() + ) + + val hiddenCards = config.computeHiddenCards() + + assertThat(hiddenCards).isEmpty() + } + + @Test + fun `when no cards visible, then hiddenCards returns all cards`() { + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + + val hiddenCards = config.computeHiddenCards() + + 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..c36615145ddf --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/InsightsViewModelTest.kt @@ -0,0 +1,906 @@ +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 +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.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.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.ui.newstats.repository.StatsTagsUseCase +import org.wordpress.android.util.NetworkUtilsWrapper + +@Suppress("LargeClass") +@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 + + @Mock + private lateinit var statsSummaryUseCase: + StatsSummaryUseCase + + @Mock + private lateinit var statsInsightsUseCase: + StatsInsightsUseCase + + @Mock + private lateinit var statsTagsUseCase: + StatsTagsUseCase + + private lateinit var viewModel: InsightsViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + private val configurationFlow = + MutableStateFlow< + Pair? + >(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, + statsSummaryUseCase, + statsInsightsUseCase, + statsTagsUseCase + ) + } + + @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 config is not loaded`() = + test { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + viewModel = InsightsViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper, + statsSummaryUseCase, + statsInsightsUseCase, + statsTagsUseCase + ) + advanceUntilIdle() + + 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(any(), any()) + } + + @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, + statsSummaryUseCase, + statsInsightsUseCase, + statsTagsUseCase + ) + + 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() + } + + // 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, + times(1)) + .invoke(any(), any()) + verify(statsInsightsUseCase, + 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 { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + + val config = InsightsCardsConfiguration( + visibleCards = emptyList() + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.fetchData() + advanceUntilIdle() + + verify(statsSummaryUseCase, never()) + .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 { + 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 new file mode 100644 index 000000000000..b8b683342ece --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/alltimestats/AllTimeStatsViewModelTest.kt @@ -0,0 +1,141 @@ +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.ui.newstats.datasource.StatsSummaryData +import org.wordpress.android.ui.newstats.repository.StatsSummaryResult +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class AllTimeStatsViewModelTest : BaseUnitTest() { + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: AllTimeStatsViewModel + + @Before + fun setUp() { + whenever( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(FAILED_TO_LOAD_ERROR) + viewModel = AllTimeStatsViewModel( + resourceProvider + ) + } + + @Test + fun `initial state is Loading`() { + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loading::class.java + ) + } + + @Test + 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) + } + } + + @Test + fun `when handleResult with error, then error state`() { + viewModel.handleResult( + StatsSummaryResult.Error("Network error") + ) + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + AllTimeStatsCardUiState.Error::class.java + ) + assertThat( + (state as AllTimeStatsCardUiState.Error) + .message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when showLoading called, then loading state`() { + viewModel.handleResult( + StatsSummaryResult.Success(createTestData()) + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + + viewModel.showLoading() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loading::class.java + ) + } + + @Test + fun `when handleResult after error, then loaded state`() { + viewModel.handleResult( + StatsSummaryResult.Error("error") + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Error::class.java + ) + + viewModel.handleResult( + StatsSummaryResult.Success(createTestData()) + ) + assertThat(viewModel.uiState.value) + .isInstanceOf( + AllTimeStatsCardUiState + .Loaded::class.java + ) + } + + companion object { + 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 FAILED_TO_LOAD_ERROR = + "Failed to load stats" + + 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..5d188df4edd4 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostpopularday/MostPopularDayViewModelTest.kt @@ -0,0 +1,178 @@ +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 +import org.mockito.Mockito.lenient +import org.wordpress.android.BaseUnitTest +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() { + @Mock + private lateinit var resourceProvider: + ResourceProvider + + 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 + ) + ).thenReturn(FAILED_TO_LOAD_ERROR) + viewModel = MostPopularDayViewModel( + resourceProvider + ) + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + + @Test + fun `initial state is Loading`() { + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Loading::class.java + ) + } + + @Test + 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 handleResult with error, then error state`() { + viewModel.handleResult( + StatsSummaryResult.Error("Network error") + ) + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Error::class.java + ) + } + + @Test + fun `when showLoading called, then loading state`() { + viewModel.handleResult( + StatsSummaryResult.Success(createTestData()) + ) + viewModel.showLoading() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + MostPopularDayCardUiState + .Loading::class.java + ) + } + + @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_VIEWS = 6782856L + private const val TEST_BEST_DAY = "2022-02-22" + private const val TEST_BEST_DAY_TOTAL = 4600L + private const val FAILED_TO_LOAD_ERROR = + "Failed to load stats" + + 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/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 new file mode 100644 index 000000000000..7fef1e148089 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/InsightsCardsConfigurationRepositoryTest.kt @@ -0,0 +1,582 @@ +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).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, + InsightsCardType.MOST_POPULAR_TIME, + InsightsCardType.TAGS_AND_CATEGORIES + ) + 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", + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" + ] + } + """.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, + InsightsCardType.MOST_POPULAR_TIME, + InsightsCardType.TAGS_AND_CATEGORIES + ) + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) + } + + @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 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", + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" + ] + } + """.trimIndent() + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(emptyJson) + + repository.addCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), any() + ) + } + + @Test + fun `when removeCard is called, then card is removed from visible cards`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.removeCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setStatsInsightsCardsConfigurationJson( + eq(TEST_SITE_ID), jsonCaptor.capture() + ) + 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 + fun `when addCard is called, then card is added to visible cards`() = + test { + val initialJson = """ + { + "visibleCards": [], + "hiddenCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" + ] + } + """.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 mutation occurs, then configurationFlow emits site id and configuration`() = + test { + val json = """ + { + "visibleCards": [], + "hiddenCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" + ] + } + """.trimIndent() + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(json) + + repository.addCard( + TEST_SITE_ID, + InsightsCardType.YEAR_IN_REVIEW + ) + + val flowValue = + repository.configurationFlow.value + assertThat(flowValue).isNotNull + assertThat(flowValue?.first) + .isEqualTo(TEST_SITE_ID) + assertThat(flowValue?.second?.visibleCards) + .contains( + 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() + ) + } + + @Test + fun `when addCard is called with existing card, then card is not duplicated`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + 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 { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + 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 { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardDown( + TEST_SITE_ID, + InsightsCardType.TAGS_AND_CATEGORIES + ) + + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) + } + + @Test + fun `when moveCardToTop on first card, then order unchanged`() = + test { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + 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 { + whenever( + appPrefsWrapper + .getStatsInsightsCardsConfigurationJson( + TEST_SITE_ID + ) + ).thenReturn(ALL_CARDS_JSON) + + repository.moveCardToBottom( + TEST_SITE_ID, + InsightsCardType.TAGS_AND_CATEGORIES + ) + + verify( + appPrefsWrapper, + org.mockito.kotlin.never() + ).setStatsInsightsCardsConfigurationJson( + any(), any() + ) + } + + @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 = """ + { + "visibleCards": [ + "YEAR_IN_REVIEW", + "ALL_TIME_STATS", + "MOST_POPULAR_DAY", + "MOST_POPULAR_TIME", + "TAGS_AND_CATEGORIES" + ] + } + """.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..fffa9fac7450 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsInsightsUseCaseTest.kt @@ -0,0 +1,254 @@ +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) + } + + @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, + 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 new file mode 100644 index 000000000000..b842daaf78f6 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryInsightsTest.kt @@ -0,0 +1,178 @@ +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.data.years).hasSize(2) + assertThat(success.data.years[0].year).isEqualTo("2025") + assertThat(success.data.years[0].totalPosts) + .isEqualTo(TEST_TOTAL_POSTS) + assertThat(success.data.years[0].totalWords) + .isEqualTo(TEST_TOTAL_WORDS) + assertThat(success.data.years[0].totalLikes) + .isEqualTo(TEST_TOTAL_LIKES) + assertThat(success.data.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( + highestHour = 0, + highestHourPercent = 0.0, + highestDayOfWeek = 0, + highestDayPercent = 0.0, + 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.data.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 + ) + } + + @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.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", + 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/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/repository/StatsSummaryUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt new file mode 100644 index 000000000000..dbd6d461de2a --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsSummaryUseCaseTest.kt @@ -0,0 +1,174 @@ +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) + } + + @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, + 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/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/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/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 new file mode 100644 index 000000000000..17dd50a6490a --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt @@ -0,0 +1,573 @@ +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.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 + +@ExperimentalCoroutinesApi +class TagsAndCategoriesViewModelTest : 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: + TagsAndCategoriesViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(testSite) + } + + 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 initViewModel() { + viewModel = TagsAndCategoriesViewModel( + selectedSiteRepository, + statsTagsUseCase, + resourceProvider, + mapper + ) + } + + // 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 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 + ) + } + + @Test + fun `when exception is thrown, then error state with localized message`() = + 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() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + + @Test + fun `when fetch succeeds, then items are mapped correctly`() = + 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) + 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( + 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) + } + + @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( + statsTagsUseCase(any(), 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( + statsTagsUseCase(any(), 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( + statsTagsUseCase(any(), 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( + statsTagsUseCase(any(), 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( + 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) + } + // endregion + + // region loadData guards + @Test + fun `when loadData called twice, then fetch only once`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + viewModel.loadData() + advanceUntilIdle() + + verify(statsTagsUseCase, times(1)) + .invoke(eq(TEST_SITE_ID), any(), any()) + } + // endregion + + // region refresh + @Test + fun `when refresh, then data is re-fetched`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + viewModel.refresh() + advanceUntilIdle() + + verify(statsTagsUseCase, times(2)) + .invoke(eq(TEST_SITE_ID), any(), any()) + } + + @Test + fun `when refresh after error, then loaded state`() = + 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.refresh() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + TagsAndCategoriesCardUiState + .Loaded::class.java + ) + } + // endregion + + // region refresh sets loading + @Test + fun `when refresh with no site, then error`() = + test { + stubNoSiteError() + whenever( + statsTagsUseCase(any(), 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( + 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 + + 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 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/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 new file mode 100644 index 000000000000..13740d4f4cb6 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/yearinreview/YearInReviewViewModelTest.kt @@ -0,0 +1,247 @@ +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.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +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.viewmodel.ResourceProvider +import java.time.Year + +@ExperimentalCoroutinesApi +class YearInReviewViewModelTest : BaseUnitTest() { + @Mock + private lateinit var resourceProvider: + ResourceProvider + + private lateinit var viewModel: YearInReviewViewModel + + @Before + fun setUp() { + whenever( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(FAILED_TO_LOAD_ERROR) + viewModel = YearInReviewViewModel( + resourceProvider + ) + } + + @Test + fun `initial state is Loading`() { + assertThat(viewModel.uiState.value) + .isInstanceOf( + YearInReviewCardUiState + .Loading::class.java + ) + } + + @Test + fun `when handleResult with success, then loaded state is emitted`() { + viewModel.handleResult( + createSuccessResult(), + ) + + 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 handleResult with error, then error state is emitted`() { + viewModel.handleResult( + InsightsResult.Error("Network error"), + ) + + 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 showLoading called, then loading state`() { + viewModel.handleResult( + createSuccessResult(), + ) + viewModel.showLoading() + + assertThat(viewModel.uiState.value) + .isInstanceOf( + YearInReviewCardUiState + .Loading::class.java + ) + } + + @Test + fun `when data loads with empty years, then current year is added`() { + viewModel.handleResult( + InsightsResult.Success( + data = createTestInsightsData( + emptyList() + ) + ), + ) + + 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`() { + 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 + ) + ) + viewModel.handleResult( + InsightsResult.Success( + data = createTestInsightsData( + yearsWithCurrent + ) + ), + ) + + 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`() { + viewModel.handleResult( + createSuccessResult(), + ) + + 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 loading, then getDetailData returns empty list`() { + val detailData = viewModel.getDetailData() + assertThat(detailData).isEmpty() + } + + @Test + fun `when state is error, then getDetailData returns empty list`() { + viewModel.handleResult( + InsightsResult.Error("error"), + ) + + assertThat(viewModel.uiState.value) + .isInstanceOf( + YearInReviewCardUiState + .Error::class.java + ) + val detailData = viewModel.getDetailData() + assertThat(detailData).isEmpty() + } + + private fun createSuccessResult() = + InsightsResult.Success( + data = createTestInsightsData( + createTestYears() + ) + ) + + private fun createTestInsightsData( + years: List + ) = StatsInsightsData( + highestHour = 14, + highestHourPercent = 15.5, + highestDayOfWeek = 3, + highestDayPercent = 25.0, + years = years + ) + + 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_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 FAILED_TO_LOAD_ERROR = + "Failed to load stats" + private val CURRENT_YEAR = + Year.now().toString() + } +} 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'