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'