diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml
index 43d4d1b0531f..941658cca740 100644
--- a/WordPress/src/main/AndroidManifest.xml
+++ b/WordPress/src/main/AndroidManifest.xml
@@ -122,6 +122,11 @@
android:theme="@style/WordPress.NoActionBar"
android:exported="false" />
+
+
AuthorsCard(
+ uiState = authorsUiState,
+ onShowAllClick = {
+ val detailData = authorsViewModel.getDetailData()
+ AuthorsDetailActivity.start(
+ context = context,
+ authors = detailData.authors,
+ totalViews = detailData.totalViews,
+ totalViewsChange = detailData.totalViewsChange,
+ totalViewsChangePercent = detailData.totalViewsChangePercent,
+ dateRange = detailData.dateRange
+ )
+ },
+ onRetry = authorsViewModel::onRetry,
+ onRemoveCard = { newStatsViewModel.removeCard(cardType) },
+ cardPosition = cardPosition,
+ onMoveUp = { newStatsViewModel.moveCardUp(cardType) },
+ onMoveToTop = { newStatsViewModel.moveCardToTop(cardType) },
+ onMoveDown = { newStatsViewModel.moveCardDown(cardType) },
+ onMoveToBottom = { newStatsViewModel.moveCardToBottom(cardType) }
+ )
}
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt
index b67f3c55a9cb..380750771106 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt
@@ -15,7 +15,8 @@ enum class StatsCardType(
VIEWS_STATS(R.string.stats_views, 1),
MOST_VIEWED_POSTS_AND_PAGES(R.string.stats_most_viewed_posts_and_pages, 2),
MOST_VIEWED_REFERRERS(R.string.stats_most_viewed_referrers, 3),
- COUNTRIES(R.string.stats_countries_title, 4);
+ COUNTRIES(R.string.stats_countries_title, 4),
+ AUTHORS(R.string.stats_authors_title, 5);
companion object {
/**
@@ -26,7 +27,8 @@ enum class StatsCardType(
VIEWS_STATS,
MOST_VIEWED_POSTS_AND_PAGES,
MOST_VIEWED_REFERRERS,
- COUNTRIES
+ COUNTRIES,
+ AUTHORS
)
}
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCard.kt
new file mode 100644
index 000000000000..211d534b3cd4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCard.kt
@@ -0,0 +1,258 @@
+package org.wordpress.android.ui.newstats.authors
+
+import androidx.compose.foundation.background
+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.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+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.StatsCardErrorContent
+import org.wordpress.android.ui.newstats.components.StatsCardHeader
+import org.wordpress.android.ui.newstats.components.StatsListHeader
+import org.wordpress.android.ui.newstats.components.StatsListItem
+import org.wordpress.android.ui.newstats.components.StatsViewChange
+import org.wordpress.android.ui.newstats.util.ShimmerBox
+
+private val CardPadding = 16.dp
+private const val LOADING_ITEM_COUNT = 4
+
+@Composable
+fun AuthorsCard(
+ uiState: AuthorsCardUiState,
+ 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 AuthorsCardUiState.Loading -> LoadingContent()
+ is AuthorsCardUiState.Loaded -> LoadedContent(
+ uiState, onShowAllClick, onRemoveCard,
+ cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom
+ )
+ is AuthorsCardUiState.Error -> StatsCardErrorContent(
+ titleResId = R.string.stats_authors_title,
+ errorMessage = uiState.message,
+ onRetry = onRetry,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ }
+ }
+}
+
+@Composable
+private fun LoadingContent() {
+ Column(modifier = Modifier.padding(CardPadding)) {
+ // Title placeholder
+ ShimmerBox(
+ modifier = Modifier
+ .width(100.dp)
+ .height(20.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // List items placeholders
+ repeat(LOADING_ITEM_COUNT) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ShimmerBox(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ ShimmerBox(
+ modifier = Modifier
+ .weight(1f)
+ .height(16.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ ShimmerBox(
+ modifier = Modifier
+ .width(50.dp)
+ .height(16.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadedContent(
+ state: AuthorsCardUiState.Loaded,
+ onShowAllClick: () -> Unit,
+ onRemoveCard: () -> Unit,
+ cardPosition: CardPosition?,
+ onMoveUp: (() -> Unit)?,
+ onMoveToTop: (() -> Unit)?,
+ onMoveDown: (() -> Unit)?,
+ onMoveToBottom: (() -> Unit)?
+) {
+ Column(modifier = Modifier.padding(CardPadding)) {
+ StatsCardHeader(
+ titleResId = R.string.stats_authors_title,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (state.authors.isEmpty()) {
+ StatsCardEmptyContent()
+ } else {
+ StatsListHeader(leftHeaderResId = R.string.stats_authors_author_header)
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Author list (capped at 10 items)
+ state.authors.forEachIndexed { index, author ->
+ val percentage = if (state.maxViewsForBar > 0) {
+ author.views.toFloat() / state.maxViewsForBar.toFloat()
+ } else 0f
+ AuthorRow(author = author, percentage = percentage)
+ if (index < state.authors.lastIndex) {
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+
+ // Show All footer
+ Spacer(modifier = Modifier.height(12.dp))
+ ShowAllFooter(onClick = onShowAllClick)
+ }
+ }
+}
+
+@Composable
+private fun AuthorRow(
+ author: AuthorUiItem,
+ percentage: Float
+) {
+ StatsListItem(
+ percentage = percentage,
+ name = author.name,
+ views = author.views,
+ change = author.change,
+ icon = { AuthorAvatar(avatarUrl = author.avatarUrl, name = author.name) }
+ )
+}
+
+@Composable
+fun AuthorAvatar(
+ avatarUrl: String?,
+ name: String,
+ modifier: Modifier = Modifier,
+ size: Dp = 40.dp
+) {
+ if (avatarUrl != null) {
+ AsyncImage(
+ model = avatarUrl,
+ contentDescription = name,
+ modifier = modifier
+ .size(size)
+ .clip(CircleShape)
+ )
+ } else {
+ Box(
+ modifier = modifier
+ .size(size)
+ .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(size * 0.6f)
+ )
+ }
+ }
+}
+
+// Previews
+@Preview(showBackground = true)
+@Composable
+private fun AuthorsCardLoadingPreview() {
+ AppThemeM3 {
+ AuthorsCard(
+ uiState = AuthorsCardUiState.Loading,
+ onShowAllClick = {},
+ onRetry = {},
+ onRemoveCard = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AuthorsCardLoadedPreview() {
+ AppThemeM3 {
+ AuthorsCard(
+ uiState = AuthorsCardUiState.Loaded(
+ authors = listOf(
+ AuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)),
+ AuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)),
+ AuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)),
+ AuthorUiItem("Alice Brown", null, 485, StatsViewChange.NoChange)
+ ),
+ maxViewsForBar = 3464,
+ hasMoreItems = true
+ ),
+ onShowAllClick = {},
+ onRetry = {},
+ onRemoveCard = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AuthorsCardErrorPreview() {
+ AppThemeM3 {
+ AuthorsCard(
+ uiState = AuthorsCardUiState.Error("Failed to load author data"),
+ onShowAllClick = {},
+ onRetry = {},
+ onRemoveCard = {}
+ )
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCardUiState.kt
new file mode 100644
index 000000000000..846089ac4d26
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCardUiState.kt
@@ -0,0 +1,36 @@
+package org.wordpress.android.ui.newstats.authors
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import org.wordpress.android.ui.newstats.components.StatsViewChange
+
+/**
+ * UI State for the Authors stats card.
+ */
+sealed class AuthorsCardUiState {
+ data object Loading : AuthorsCardUiState()
+
+ data class Loaded(
+ val authors: List,
+ val maxViewsForBar: Long,
+ val hasMoreItems: Boolean
+ ) : AuthorsCardUiState()
+
+ data class Error(val message: String) : AuthorsCardUiState()
+}
+
+/**
+ * A single author item in the authors list.
+ *
+ * @param name The author's display name
+ * @param avatarUrl URL to the author's avatar image
+ * @param views Number of views from this author's posts
+ * @param change The change compared to the previous period
+ */
+@Parcelize
+data class AuthorUiItem(
+ val name: String,
+ val avatarUrl: String?,
+ val views: Long,
+ val change: StatsViewChange = StatsViewChange.NoChange
+) : Parcelable
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt
new file mode 100644
index 000000000000..54e477f9b237
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt
@@ -0,0 +1,204 @@
+package org.wordpress.android.ui.newstats.authors
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.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.StatsDetailListItem
+import org.wordpress.android.ui.newstats.components.StatsListHeader
+import org.wordpress.android.ui.newstats.components.StatsSummaryCard
+import org.wordpress.android.ui.newstats.components.StatsViewChange
+import org.wordpress.android.util.extensions.getParcelableArrayListCompat
+
+private const val EXTRA_AUTHORS = "extra_authors"
+private const val EXTRA_TOTAL_VIEWS = "extra_total_views"
+private const val EXTRA_TOTAL_VIEWS_CHANGE = "extra_total_views_change"
+private const val EXTRA_TOTAL_VIEWS_CHANGE_PERCENT = "extra_total_views_change_percent"
+private const val EXTRA_DATE_RANGE = "extra_date_range"
+
+@AndroidEntryPoint
+class AuthorsDetailActivity : BaseAppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val authors = intent.extras
+ ?.getParcelableArrayListCompat(EXTRA_AUTHORS)
+ ?: arrayListOf()
+ val totalViews = intent.getLongExtra(EXTRA_TOTAL_VIEWS, 0L)
+ val totalViewsChange = intent.getLongExtra(EXTRA_TOTAL_VIEWS_CHANGE, 0L)
+ val totalViewsChangePercent = intent.getDoubleExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, 0.0)
+ val dateRange = intent.getStringExtra(EXTRA_DATE_RANGE) ?: ""
+ // Calculate maxViewsForBar once (list is sorted by views descending)
+ val maxViewsForBar = authors.firstOrNull()?.views ?: 0L
+
+ setContent {
+ AppThemeM3 {
+ AuthorsDetailScreen(
+ authors = authors,
+ maxViewsForBar = maxViewsForBar,
+ totalViews = totalViews,
+ totalViewsChange = totalViewsChange,
+ totalViewsChangePercent = totalViewsChangePercent,
+ dateRange = dateRange,
+ onBackPressed = onBackPressedDispatcher::onBackPressed
+ )
+ }
+ }
+ }
+
+ companion object {
+ @Suppress("LongParameterList")
+ fun start(
+ context: Context,
+ authors: List,
+ totalViews: Long,
+ totalViewsChange: Long,
+ totalViewsChangePercent: Double,
+ dateRange: String
+ ) {
+ val intent = Intent(context, AuthorsDetailActivity::class.java).apply {
+ putExtra(EXTRA_AUTHORS, ArrayList(authors))
+ putExtra(EXTRA_TOTAL_VIEWS, totalViews)
+ putExtra(EXTRA_TOTAL_VIEWS_CHANGE, totalViewsChange)
+ putExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, totalViewsChangePercent)
+ putExtra(EXTRA_DATE_RANGE, dateRange)
+ }
+ context.startActivity(intent)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AuthorsDetailScreen(
+ authors: List,
+ maxViewsForBar: Long,
+ totalViews: Long,
+ totalViewsChange: Long,
+ totalViewsChangePercent: Double,
+ dateRange: String,
+ onBackPressed: () -> Unit
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(text = stringResource(R.string.stats_authors_title)) },
+ 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)
+ ) {
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ StatsSummaryCard(
+ totalViews = totalViews,
+ dateRange = dateRange,
+ totalViewsChange = totalViewsChange,
+ totalViewsChangePercent = totalViewsChangePercent
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ item {
+ StatsListHeader(leftHeaderResId = R.string.stats_authors_author_header)
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ itemsIndexed(authors) { index, author ->
+ val percentage = if (maxViewsForBar > 0) {
+ author.views.toFloat() / maxViewsForBar.toFloat()
+ } else 0f
+ DetailAuthorRow(
+ position = index + 1,
+ author = author,
+ percentage = percentage
+ )
+ if (index < authors.lastIndex) {
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun DetailAuthorRow(
+ position: Int,
+ author: AuthorUiItem,
+ percentage: Float
+) {
+ StatsDetailListItem(
+ position = position,
+ percentage = percentage,
+ name = author.name,
+ views = author.views,
+ change = author.change,
+ icon = { AuthorAvatar(avatarUrl = author.avatarUrl, name = author.name) }
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AuthorsDetailScreenPreview() {
+ AppThemeM3 {
+ AuthorsDetailScreen(
+ authors = listOf(
+ AuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)),
+ AuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)),
+ AuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)),
+ AuthorUiItem("Alice Brown", null, 485, StatsViewChange.Positive(33, 7.3)),
+ AuthorUiItem("Charlie Wilson", null, 412, StatsViewChange.NoChange),
+ AuthorUiItem("Diana Miller", null, 387, StatsViewChange.Negative(8, 2.0)),
+ AuthorUiItem("Edward Davis", null, 298, StatsViewChange.Positive(21, 7.6)),
+ AuthorUiItem("Fiona Garcia", null, 245, StatsViewChange.Positive(15, 6.5)),
+ AuthorUiItem("George Martinez", null, 201, StatsViewChange.Negative(5, 2.4)),
+ AuthorUiItem("Hannah Anderson", null, 156, StatsViewChange.Positive(12, 8.3))
+ ),
+ maxViewsForBar = 3464,
+ totalViews = 6726,
+ totalViewsChange = 225,
+ totalViewsChangePercent = 3.5,
+ dateRange = "Last 7 days",
+ onBackPressed = {}
+ )
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt
new file mode 100644
index 000000000000..a61ecbdb7eff
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt
@@ -0,0 +1,170 @@
+package org.wordpress.android.ui.newstats.authors
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.ui.mysite.SelectedSiteRepository
+import org.wordpress.android.ui.newstats.StatsPeriod
+import org.wordpress.android.ui.newstats.components.StatsViewChange
+import org.wordpress.android.ui.newstats.repository.StatsRepository
+import org.wordpress.android.ui.newstats.repository.TopAuthorItemData
+import org.wordpress.android.R
+import org.wordpress.android.ui.newstats.repository.TopAuthorsResult
+import org.wordpress.android.ui.newstats.util.toDateRangeString
+import org.wordpress.android.viewmodel.ResourceProvider
+import javax.inject.Inject
+import kotlin.math.abs
+
+private const val CARD_MAX_ITEMS = 10
+
+@HiltViewModel
+class AuthorsViewModel @Inject constructor(
+ private val selectedSiteRepository: SelectedSiteRepository,
+ private val accountStore: AccountStore,
+ private val statsRepository: StatsRepository,
+ private val resourceProvider: ResourceProvider
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(AuthorsCardUiState.Loading)
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _isRefreshing = MutableStateFlow(false)
+ val isRefreshing: StateFlow = _isRefreshing.asStateFlow()
+
+ private var currentPeriod: StatsPeriod = StatsPeriod.Last7Days
+
+ private var allAuthors: List = emptyList()
+ private var cachedTotalViews: Long = 0L
+ private var cachedTotalViewsChange: Long = 0L
+ private var cachedTotalViewsChangePercent: Double = 0.0
+
+ init {
+ loadData()
+ }
+
+ fun loadData() {
+ val site = selectedSiteRepository.getSelectedSite()
+ if (site == null) {
+ _uiState.value = AuthorsCardUiState.Error(
+ resourceProvider.getString(R.string.stats_todays_stats_no_site_selected)
+ )
+ return
+ }
+
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) {
+ _uiState.value = AuthorsCardUiState.Error(
+ resourceProvider.getString(R.string.stats_todays_stats_failed_to_load)
+ )
+ return
+ }
+
+ statsRepository.init(accessToken)
+ _uiState.value = AuthorsCardUiState.Loading
+
+ viewModelScope.launch {
+ fetchTopAuthors(site)
+ }
+ }
+
+ fun refresh() {
+ val site = selectedSiteRepository.getSelectedSite() ?: return
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) return
+
+ statsRepository.init(accessToken)
+ viewModelScope.launch {
+ _isRefreshing.value = true
+ fetchTopAuthors(site)
+ _isRefreshing.value = false
+ }
+ }
+
+ fun onRetry() {
+ loadData()
+ }
+
+ fun onPeriodChanged(period: StatsPeriod) {
+ if (currentPeriod != period) {
+ currentPeriod = period
+ loadData()
+ }
+ }
+
+ fun getDetailData(): AuthorsDetailData {
+ return AuthorsDetailData(
+ authors = allAuthors,
+ totalViews = cachedTotalViews,
+ totalViewsChange = cachedTotalViewsChange,
+ totalViewsChangePercent = cachedTotalViewsChangePercent,
+ dateRange = currentPeriod.toDateRangeString(resourceProvider)
+ )
+ }
+
+ private suspend fun fetchTopAuthors(site: SiteModel) {
+ val siteId = site.siteId
+
+ when (val result = statsRepository.fetchTopAuthors(siteId, currentPeriod)) {
+ is TopAuthorsResult.Success -> {
+ cachedTotalViews = result.totalViews
+ cachedTotalViewsChange = result.totalViewsChange
+ cachedTotalViewsChangePercent = result.totalViewsChangePercent
+
+ if (result.authors.isEmpty()) {
+ allAuthors = emptyList()
+ _uiState.value = AuthorsCardUiState.Loaded(
+ authors = emptyList(),
+ maxViewsForBar = 0,
+ hasMoreItems = false
+ )
+ } else {
+ val authors = result.authors.map { author ->
+ AuthorUiItem(
+ name = author.name,
+ avatarUrl = author.avatarUrl,
+ views = author.views,
+ change = author.toStatsViewChange()
+ )
+ }
+
+ // Store all authors for detail screen
+ allAuthors = authors
+
+ // For bar percentage, use first item's views (list is sorted by views descending)
+ val cardAuthors = authors.take(CARD_MAX_ITEMS)
+ val maxViewsForBar = cardAuthors.firstOrNull()?.views ?: 0L
+
+ _uiState.value = AuthorsCardUiState.Loaded(
+ authors = cardAuthors,
+ maxViewsForBar = maxViewsForBar,
+ hasMoreItems = authors.size > CARD_MAX_ITEMS
+ )
+ }
+ }
+ is TopAuthorsResult.Error -> {
+ _uiState.value = AuthorsCardUiState.Error(result.message)
+ }
+ }
+ }
+
+ private fun TopAuthorItemData.toStatsViewChange(): StatsViewChange {
+ return when {
+ viewsChange > 0 -> StatsViewChange.Positive(viewsChange, abs(viewsChangePercent))
+ viewsChange < 0 -> StatsViewChange.Negative(abs(viewsChange), abs(viewsChangePercent))
+ else -> StatsViewChange.NoChange
+ }
+ }
+}
+
+data class AuthorsDetailData(
+ val authors: List,
+ val totalViews: Long,
+ val totalViewsChange: Long,
+ val totalViewsChangePercent: Double,
+ val dateRange: String
+)
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
new file mode 100644
index 000000000000..fa5e382f6999
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt
@@ -0,0 +1,372 @@
+package org.wordpress.android.ui.newstats.components
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+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.filled.ChevronRight
+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.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.wordpress.android.R
+import org.wordpress.android.ui.newstats.util.formatStatValue
+
+private val CardCornerRadius = 10.dp
+private val CardPadding = 16.dp
+private val CardMargin = 16.dp
+
+/**
+ * Common card container with border, background, and rounded corners.
+ * Used by stats cards (Countries, Authors, etc.) for consistent styling.
+ */
+@Composable
+fun StatsCardContainer(
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) {
+ 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)
+ ) {
+ content()
+ }
+}
+
+/**
+ * Common card header with title and menu.
+ * Used by stats cards for consistent header styling.
+ */
+@Composable
+fun StatsCardHeader(
+ @StringRes titleResId: Int,
+ onRemoveCard: () -> Unit,
+ cardPosition: CardPosition?,
+ onMoveUp: (() -> Unit)?,
+ onMoveToTop: (() -> Unit)?,
+ onMoveDown: (() -> Unit)?,
+ onMoveToBottom: (() -> Unit)?
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(titleResId),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ StatsCardMenu(
+ onRemoveClick = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ }
+}
+
+/**
+ * Common empty content state.
+ * Displays "No data yet" message when there's no data to show.
+ */
+@Composable
+fun StatsCardEmptyContent() {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = stringResource(R.string.stats_no_data_yet),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+/**
+ * Common error content state with retry button.
+ * Displays error message and retry button.
+ */
+@Composable
+fun StatsCardErrorContent(
+ @StringRes titleResId: Int,
+ errorMessage: String,
+ onRetry: () -> Unit,
+ onRemoveCard: () -> Unit,
+ cardPosition: CardPosition?,
+ onMoveUp: (() -> Unit)?,
+ onMoveToTop: (() -> Unit)?,
+ onMoveDown: (() -> Unit)?,
+ onMoveToBottom: (() -> Unit)?
+) {
+ Column(modifier = Modifier.padding(CardPadding)) {
+ StatsCardHeader(
+ titleResId = titleResId,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = errorMessage,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(onClick = onRetry) {
+ Text(text = stringResource(R.string.retry))
+ }
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+}
+
+/**
+ * Common "Show All" footer for stats cards.
+ * Displays a clickable row with "Show All" text and chevron icon.
+ */
+@Composable
+fun ShowAllFooter(onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.stats_show_all),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+/**
+ * Common list header row showing two column headers.
+ */
+@Composable
+fun StatsListHeader(
+ @StringRes leftHeaderResId: Int,
+ @StringRes rightHeaderResId: Int = R.string.stats_countries_views_header
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(leftHeaderResId),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringResource(rightHeaderResId),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+/**
+ * Common row container with background percentage bar.
+ * Used for country rows, author rows, and similar list items.
+ *
+ * @param percentage Fill percentage for the background bar (0f to 1f)
+ * @param content The row content to display
+ */
+@Composable
+fun StatsListRowContainer(
+ percentage: Float,
+ content: @Composable () -> Unit
+) {
+ val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min)
+ .clip(RoundedCornerShape(8.dp))
+ ) {
+ // Background bar representing the percentage
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(fraction = percentage)
+ .fillMaxHeight()
+ .background(barColor)
+ )
+
+ content()
+ }
+}
+
+/**
+ * Common views column with count and change indicator.
+ * Used in list rows to display views count and change percentage.
+ */
+@Composable
+fun StatsViewsColumn(
+ views: Long,
+ change: StatsViewChange
+) {
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = formatStatValue(views),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ StatsChangeIndicator(change = change)
+ }
+}
+
+/**
+ * Common text element for item name with ellipsis overflow.
+ */
+@Composable
+fun StatsItemName(
+ name: String,
+ modifier: Modifier = Modifier
+) {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = modifier
+ )
+}
+
+/**
+ * Common position number for detail screens.
+ */
+@Composable
+fun StatsPositionNumber(position: Int) {
+ Text(
+ text = position.toString(),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.width(32.dp)
+ )
+}
+
+/**
+ * Common list item row for stats cards.
+ * Displays an icon, name, views count and change indicator with a percentage bar background.
+ *
+ * @param percentage Fill percentage for the background bar (0f to 1f)
+ * @param name The item name to display
+ * @param views The views count
+ * @param change The change compared to previous period
+ * @param icon Composable slot for the icon (flag, avatar, etc.)
+ */
+@Composable
+fun StatsListItem(
+ percentage: Float,
+ name: String,
+ views: Long,
+ change: StatsViewChange,
+ icon: @Composable () -> Unit
+) {
+ StatsListRowContainer(percentage = percentage) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ icon()
+ Spacer(modifier = Modifier.width(12.dp))
+ StatsItemName(name = name, modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.width(12.dp))
+ StatsViewsColumn(views = views, change = change)
+ }
+ }
+}
+
+/**
+ * Common list item row for detail screens with position number.
+ * Displays position, icon, name, views count and change indicator.
+ *
+ * @param position The position number (1, 2, 3, ...)
+ * @param percentage Fill percentage for the background bar (0f to 1f)
+ * @param name The item name to display
+ * @param views The views count
+ * @param change The change compared to previous period
+ * @param icon Composable slot for the icon (flag, avatar, etc.)
+ */
+@Composable
+fun StatsDetailListItem(
+ position: Int,
+ percentage: Float,
+ name: String,
+ views: Long,
+ change: StatsViewChange,
+ icon: @Composable () -> Unit
+) {
+ StatsListRowContainer(percentage = percentage) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ StatsPositionNumber(position = position)
+ icon()
+ Spacer(modifier = Modifier.width(12.dp))
+ StatsItemName(name = name, modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.width(12.dp))
+ StatsViewsColumn(views = views, change = change)
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsViewChange.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsViewChange.kt
new file mode 100644
index 000000000000..eff4c6e0bb0c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsViewChange.kt
@@ -0,0 +1,55 @@
+package org.wordpress.android.ui.newstats.components
+
+import android.os.Parcelable
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import kotlinx.parcelize.Parcelize
+import org.wordpress.android.ui.newstats.StatsColors
+import org.wordpress.android.ui.newstats.util.formatStatValue
+import java.util.Locale
+
+/**
+ * Represents the change in views compared to the previous period.
+ * This is a shared sealed class used across different stats cards (Countries, Top Authors, etc.)
+ */
+sealed class StatsViewChange : Parcelable {
+ @Parcelize
+ data class Positive(val value: Long, val percentage: Double) : StatsViewChange()
+ @Parcelize
+ data class Negative(val value: Long, val percentage: Double) : StatsViewChange()
+ @Parcelize
+ data object NoChange : StatsViewChange()
+}
+
+/**
+ * A shared change indicator component that displays the change in views.
+ * Shows positive changes in green and negative changes in red.
+ * Does not render anything for NoChange.
+ *
+ * @param change The change value to display
+ */
+@Composable
+fun StatsChangeIndicator(change: StatsViewChange) {
+ val (text, color) = when (change) {
+ is StatsViewChange.Positive -> Pair(
+ "+${formatStatValue(change.value)} (${
+ String.format(Locale.getDefault(), "%.1f%%", change.percentage)
+ })",
+ StatsColors.ChangeBadgePositive
+ )
+ is StatsViewChange.Negative -> Pair(
+ "-${formatStatValue(change.value)} (${
+ String.format(Locale.getDefault(), "%.1f%%", change.percentage)
+ })",
+ StatsColors.ChangeBadgeNegative
+ )
+ is StatsViewChange.NoChange -> return
+ }
+
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ color = color
+ )
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt
index 5641aebb18f1..f8e683b39471 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt
@@ -1,15 +1,10 @@
package org.wordpress.android.ui.newstats.countries
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-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.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -17,32 +12,28 @@ 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.filled.ChevronRight
-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.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
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.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.StatsCardErrorContent
+import org.wordpress.android.ui.newstats.components.StatsCardHeader
+import org.wordpress.android.ui.newstats.components.StatsListHeader
+import org.wordpress.android.ui.newstats.components.StatsListItem
import org.wordpress.android.ui.newstats.util.ShimmerBox
-import org.wordpress.android.ui.newstats.util.formatStatValue
-private val CardCornerRadius = 10.dp
private val CardPadding = 16.dp
-private val CardMargin = 16.dp
private const val MAP_ASPECT_RATIO = 8f / 5f
private const val LOADING_ITEM_COUNT = 4
@@ -59,25 +50,23 @@ fun CountriesCard(
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)
- ) {
+ StatsCardContainer(modifier = modifier) {
when (uiState) {
is CountriesCardUiState.Loading -> LoadingContent()
is CountriesCardUiState.Loaded -> LoadedContent(
uiState, onShowAllClick, onRemoveCard,
cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom
)
- is CountriesCardUiState.Error -> ErrorContent(
- uiState, onRetry, onRemoveCard,
- cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom
+ is CountriesCardUiState.Error -> StatsCardErrorContent(
+ titleResId = R.string.stats_countries_title,
+ errorMessage = uiState.message,
+ onRetry = onRetry,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
)
}
}
@@ -151,31 +140,19 @@ private fun LoadedContent(
onMoveToBottom: (() -> Unit)?
) {
Column(modifier = Modifier.padding(CardPadding)) {
- // Header with menu
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.stats_countries_title),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- StatsCardMenu(
- onRemoveClick = onRemoveCard,
- cardPosition = cardPosition,
- onMoveUp = onMoveUp,
- onMoveToTop = onMoveToTop,
- onMoveDown = onMoveDown,
- onMoveToBottom = onMoveToBottom
- )
- }
+ StatsCardHeader(
+ titleResId = R.string.stats_countries_title,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
Spacer(modifier = Modifier.height(8.dp))
if (state.countries.isEmpty()) {
- EmptyContent()
+ StatsCardEmptyContent()
} else {
// Map
CountryMap(
@@ -193,22 +170,7 @@ private fun LoadedContent(
)
Spacer(modifier = Modifier.height(16.dp))
- // Header
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.stats_countries_location_header),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- text = stringResource(R.string.stats_countries_views_header),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
+ StatsListHeader(leftHeaderResId = R.string.stats_countries_location_header)
Spacer(modifier = Modifier.height(8.dp))
// Country list (capped at 10 items)
@@ -229,22 +191,6 @@ private fun LoadedContent(
}
}
-@Composable
-private fun EmptyContent() {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(100.dp),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = stringResource(R.string.stats_no_data_yet),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-}
-
@Composable
private fun CountryMap(
mapData: String,
@@ -261,150 +207,37 @@ private fun CountryRow(
country: CountryItem,
percentage: Float
) {
- val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)
-
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(IntrinsicSize.Min)
- .clip(RoundedCornerShape(8.dp))
- ) {
- // Background bar representing the percentage
- Box(
- modifier = Modifier
- .fillMaxWidth(fraction = percentage)
- .fillMaxHeight()
- .background(barColor)
- )
-
- // Content
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 12.dp, horizontal = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Flag icon
- if (country.flagIconUrl != null) {
- AsyncImage(
- model = country.flagIconUrl,
- contentDescription = country.countryName,
- modifier = Modifier.size(24.dp)
- )
- } else {
- Box(
- modifier = Modifier
- .size(24.dp)
- .background(
- MaterialTheme.colorScheme.surfaceVariant,
- RoundedCornerShape(4.dp)
- )
- )
- }
- Spacer(modifier = Modifier.width(12.dp))
-
- // Country name
- Text(
- text = country.countryName,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f)
- )
- Spacer(modifier = Modifier.width(12.dp))
-
- // Views count and change
- Column(horizontalAlignment = Alignment.End) {
- Text(
- text = formatStatValue(country.views),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- StatsChangeIndicator(change = country.change)
- }
- }
- }
+ StatsListItem(
+ percentage = percentage,
+ name = country.countryName,
+ views = country.views,
+ change = country.change.toStatsViewChange(),
+ icon = { CountryFlag(flagIconUrl = country.flagIconUrl, countryName = country.countryName) }
+ )
}
@Composable
-private fun ShowAllFooter(onClick: () -> Unit) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable(onClick = onClick)
- .padding(vertical = 8.dp),
- horizontalArrangement = Arrangement.Start,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.stats_show_all),
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
+fun CountryFlag(
+ flagIconUrl: String?,
+ countryName: String,
+ modifier: Modifier = Modifier,
+ size: Dp = 24.dp
+) {
+ if (flagIconUrl != null) {
+ AsyncImage(
+ model = flagIconUrl,
+ contentDescription = countryName,
+ modifier = modifier.size(size)
)
- Icon(
- imageVector = Icons.Default.ChevronRight,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.onSurface
+ } else {
+ Box(
+ modifier = modifier
+ .size(size)
+ .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))
)
}
}
-@Composable
-private fun ErrorContent(
- state: CountriesCardUiState.Error,
- onRetry: () -> Unit,
- onRemoveCard: () -> Unit,
- cardPosition: CardPosition?,
- onMoveUp: (() -> Unit)?,
- onMoveToTop: (() -> Unit)?,
- onMoveDown: (() -> Unit)?,
- onMoveToBottom: (() -> Unit)?
-) {
- Column(modifier = Modifier.padding(CardPadding)) {
- // Header with menu
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.stats_countries_title),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- StatsCardMenu(
- onRemoveClick = onRemoveCard,
- cardPosition = cardPosition,
- onMoveUp = onMoveUp,
- onMoveToTop = onMoveToTop,
- onMoveDown = onMoveDown,
- onMoveToBottom = onMoveToBottom
- )
- }
- Spacer(modifier = Modifier.height(24.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))
- }
- }
- Spacer(modifier = Modifier.height(24.dp))
- }
-}
-
// Previews
@Preview(showBackground = true)
@Composable
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCardUiState.kt
index 536f90d5b6f5..7bcc48464d4a 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCardUiState.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCardUiState.kt
@@ -2,6 +2,7 @@ package org.wordpress.android.ui.newstats.countries
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
+import org.wordpress.android.ui.newstats.components.StatsViewChange
/**
* UI State for the Countries stats card.
@@ -49,3 +50,12 @@ sealed class CountryViewChange : Parcelable {
@Parcelize
data object NoChange : CountryViewChange()
}
+
+/**
+ * Converts [CountryViewChange] to [StatsViewChange] for use with shared components.
+ */
+fun CountryViewChange.toStatsViewChange(): StatsViewChange = when (this) {
+ is CountryViewChange.Positive -> StatsViewChange.Positive(value, percentage)
+ is CountryViewChange.Negative -> StatsViewChange.Negative(value, percentage)
+ is CountryViewChange.NoChange -> StatsViewChange.NoChange
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesDetailActivity.kt
index 2977e11067d0..9963bfc0b1d9 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesDetailActivity.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesDetailActivity.kt
@@ -3,53 +3,38 @@ package org.wordpress.android.ui.newstats.countries
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.os.Parcelable
import androidx.activity.compose.setContent
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxHeight
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.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-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.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.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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.parcelize.Parcelize
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.StatsDetailListItem
+import org.wordpress.android.ui.newstats.components.StatsListHeader
import org.wordpress.android.ui.newstats.components.StatsSummaryCard
-import org.wordpress.android.ui.newstats.util.formatStatValue
import org.wordpress.android.util.extensions.getParcelableArrayListCompat
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
private const val EXTRA_COUNTRIES = "extra_countries"
private const val EXTRA_MAP_DATA = "extra_map_data"
@@ -77,7 +62,7 @@ class CountriesDetailActivity : BaseAppCompatActivity() {
val totalViewsChangePercent = intent.getDoubleExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, 0.0)
val dateRange = intent.getStringExtra(EXTRA_DATE_RANGE) ?: ""
// Calculate maxViewsForBar once (list is sorted by views descending)
- val maxViewsForBar = countries.firstOrNull()?.views ?: 1L
+ val maxViewsForBar = countries.firstOrNull()?.views ?: 0L
setContent {
AppThemeM3 {
@@ -180,7 +165,6 @@ private fun CountriesDetailScreen(
) {
item {
Spacer(modifier = Modifier.height(8.dp))
- // Summary card
StatsSummaryCard(
totalViews = totalViews,
dateRange = dateRange,
@@ -190,7 +174,7 @@ private fun CountriesDetailScreen(
Spacer(modifier = Modifier.height(16.dp))
// Map
- CountryMap(
+ StatsGeoChartWebView(
mapData = mapData,
modifier = Modifier
.fillMaxWidth()
@@ -203,24 +187,8 @@ private fun CountriesDetailScreen(
Spacer(modifier = Modifier.height(16.dp))
}
-
item {
- // Header
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.stats_countries_location_header),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- Text(
- text = stringResource(R.string.stats_countries_views_header),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
+ StatsListHeader(leftHeaderResId = R.string.stats_countries_location_header)
Spacer(modifier = Modifier.height(8.dp))
}
@@ -245,96 +213,20 @@ private fun CountriesDetailScreen(
}
}
-@Composable
-private fun CountryMap(
- mapData: String,
- modifier: Modifier = Modifier
-) {
- StatsGeoChartWebView(
- mapData = mapData,
- modifier = modifier
- )
-}
-
@Composable
private fun DetailCountryRow(
position: Int,
country: CountriesDetailItem,
percentage: Float
) {
- val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)
-
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(IntrinsicSize.Min)
- .clip(RoundedCornerShape(8.dp))
- ) {
- // Background bar representing the percentage
- Box(
- modifier = Modifier
- .fillMaxWidth(fraction = percentage)
- .fillMaxHeight()
- .background(barColor)
- )
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 12.dp, horizontal = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Position number
- Text(
- text = position.toString(),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.width(32.dp)
- )
-
- // Flag icon
- if (country.flagIconUrl != null) {
- AsyncImage(
- model = country.flagIconUrl,
- contentDescription = country.countryName,
- modifier = Modifier.size(24.dp)
- )
- } else {
- Box(
- modifier = Modifier
- .size(24.dp)
- .background(
- MaterialTheme.colorScheme.surfaceVariant,
- RoundedCornerShape(4.dp)
- )
- )
- }
- Spacer(modifier = Modifier.width(12.dp))
-
- // Country name
- Text(
- text = country.countryName,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f)
- )
- Spacer(modifier = Modifier.width(12.dp))
-
- // Views count and change
- Column(horizontalAlignment = Alignment.End) {
- Text(
- text = formatStatValue(country.views),
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface
- )
- StatsChangeIndicator(change = country.change)
- }
- }
- }
+ StatsDetailListItem(
+ position = position,
+ percentage = percentage,
+ name = country.countryName,
+ views = country.views,
+ change = country.change.toStatsViewChange(),
+ icon = { CountryFlag(flagIconUrl = country.flagIconUrl, countryName = country.countryName) }
+ )
}
@Preview(showBackground = true)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt
index b2ba83bd7dd8..e854a6def4c8 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt
@@ -12,6 +12,7 @@ import org.wordpress.android.fluxc.store.AccountStore
import org.wordpress.android.ui.mysite.SelectedSiteRepository
import org.wordpress.android.ui.newstats.StatsPeriod
import org.wordpress.android.ui.newstats.repository.CountryViewItemData
+import org.wordpress.android.R
import org.wordpress.android.ui.newstats.repository.CountryViewsResult
import org.wordpress.android.ui.newstats.repository.StatsRepository
import org.wordpress.android.ui.newstats.util.toDateRangeString
@@ -49,30 +50,39 @@ class CountriesViewModel @Inject constructor(
}
fun loadData() {
- viewModelScope.launch {
- _uiState.value = CountriesCardUiState.Loading
+ val site = selectedSiteRepository.getSelectedSite()
+ if (site == null) {
+ _uiState.value = CountriesCardUiState.Error(
+ resourceProvider.getString(R.string.stats_todays_stats_no_site_selected)
+ )
+ return
+ }
- val site = selectedSiteRepository.getSelectedSite()
- if (site == null) {
- _uiState.value = CountriesCardUiState.Error("No site selected")
- return@launch
- }
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) {
+ _uiState.value = CountriesCardUiState.Error(
+ resourceProvider.getString(R.string.stats_todays_stats_failed_to_load)
+ )
+ return
+ }
- initializeRepository()
+ statsRepository.init(accessToken)
+ _uiState.value = CountriesCardUiState.Loading
+
+ viewModelScope.launch {
fetchCountryViews(site)
}
}
fun refresh() {
+ val site = selectedSiteRepository.getSelectedSite() ?: return
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) return
+
+ statsRepository.init(accessToken)
viewModelScope.launch {
_isRefreshing.value = true
-
- val site = selectedSiteRepository.getSelectedSite()
- if (site != null) {
- initializeRepository()
- fetchCountryViews(site)
- }
-
+ fetchCountryViews(site)
_isRefreshing.value = false
}
}
@@ -101,12 +111,6 @@ class CountriesViewModel @Inject constructor(
)
}
- private fun initializeRepository() {
- accountStore.accessToken?.let { token ->
- statsRepository.init(token)
- }
- }
-
private suspend fun fetchCountryViews(site: SiteModel) {
val siteId = site.siteId
@@ -153,7 +157,7 @@ class CountriesViewModel @Inject constructor(
// For bar percentage, use first item's views (list is sorted by views descending)
val cardCountries = countries.take(CARD_MAX_ITEMS)
- val maxViewsForBar = cardCountries.firstOrNull()?.views ?: 1L
+ val maxViewsForBar = cardCountries.firstOrNull()?.views ?: 0L
_uiState.value = CountriesCardUiState.Loaded(
countries = cardCountries,
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 a3462b373f6a..1fd376646732 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
@@ -67,6 +67,20 @@ interface StatsDataSource {
dateRange: StatsDateRange,
max: Int = 10
): CountryViewsDataResult
+
+ /**
+ * Fetches top authors stats for a specific site.
+ *
+ * @param siteId The WordPress.com site ID
+ * @param dateRange The date range parameters for the query
+ * @param max Maximum number of authors to return
+ * @return Result containing the top authors data or an error
+ */
+ suspend fun fetchTopAuthors(
+ siteId: Long,
+ dateRange: StatsDateRange,
+ max: Int = 10
+ ): TopAuthorsDataResult
}
/**
@@ -213,3 +227,28 @@ data class CountryViewItem(
val views: Long,
val flagIconUrl: String?
)
+
+/**
+ * Result wrapper for top authors fetch operation.
+ */
+sealed class TopAuthorsDataResult {
+ data class Success(val data: TopAuthorsData) : TopAuthorsDataResult()
+ data class Error(val message: String) : TopAuthorsDataResult()
+}
+
+/**
+ * Top authors data from the API.
+ */
+data class TopAuthorsData(
+ val authors: List,
+ val totalViews: Long
+)
+
+/**
+ * A single top author item from the API.
+ */
+data class TopAuthorItem(
+ val name: String,
+ val avatarUrl: String?,
+ val views: Long
+)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt
index 61eda229a0e5..75e8d968f304 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
@@ -4,12 +4,14 @@ import org.wordpress.android.networking.restapi.WpComApiClientProvider
import org.wordpress.android.util.LocaleManagerWrapper
import rs.wordpress.api.kotlin.WpComApiClient
import rs.wordpress.api.kotlin.WpRequestResult
+import uniffi.wp_api.StatsCountryViewsParams
+import uniffi.wp_api.StatsCountryViewsPeriod
import uniffi.wp_api.StatsReferrersParams
import uniffi.wp_api.StatsReferrersPeriod
+import uniffi.wp_api.StatsTopAuthorsParams
+import uniffi.wp_api.StatsTopAuthorsPeriod
import uniffi.wp_api.StatsTopPostsParams
import uniffi.wp_api.StatsTopPostsPeriod
-import uniffi.wp_api.StatsCountryViewsParams
-import uniffi.wp_api.StatsCountryViewsPeriod
import uniffi.wp_api.StatsVisitsParams
import uniffi.wp_api.StatsVisitsUnit
import uniffi.wp_api.WpComLanguage
@@ -317,4 +319,88 @@ class StatsDataSourceImpl @Inject constructor(
}
}
}
+
+ private fun buildTopAuthorsParams(dateRange: StatsDateRange, max: Int) = when (dateRange) {
+ is StatsDateRange.Preset -> StatsTopAuthorsParams(
+ period = StatsTopAuthorsPeriod.DAY,
+ date = dateRange.date,
+ num = dateRange.num.toUInt(),
+ max = if (max > 0) max.toUInt() else null,
+ locale = wpComLanguage,
+ summarize = true
+ )
+ is StatsDateRange.Custom -> StatsTopAuthorsParams(
+ period = StatsTopAuthorsPeriod.DAY,
+ date = dateRange.date,
+ startDate = dateRange.startDate,
+ max = if (max > 0) max.toUInt() else null,
+ locale = wpComLanguage,
+ summarize = true
+ )
+ }
+
+ override suspend fun fetchTopAuthors(
+ siteId: Long,
+ dateRange: StatsDateRange,
+ max: Int
+ ): TopAuthorsDataResult {
+ val params = buildTopAuthorsParams(dateRange, max)
+ AppLog.d(T.STATS, "fetchTopAuthors - siteId=$siteId, dateRange=$dateRange, max=$max")
+
+ val result = wpComApiClient.request { requestBuilder ->
+ requestBuilder.statsTopAuthors().getStatsTopAuthors(
+ wpComSiteId = siteId.toULong(),
+ params = params
+ )
+ }
+
+ AppLog.d(T.STATS, "StatsDataSourceImpl: fetchTopAuthors result type: ${result::class.simpleName}")
+
+ return when (result) {
+ is WpRequestResult.Success -> {
+ val authors = result.response.data.summary?.authors.orEmpty()
+ AppLog.d(
+ T.STATS,
+ "StatsDataSourceImpl: fetchTopAuthors success - ${authors.size} authors"
+ )
+
+ val authorItems = authors.map { author ->
+ TopAuthorItem(
+ name = author.name,
+ avatarUrl = author.avatar,
+ views = author.views.toLong()
+ )
+ }
+ val totalViews = authorItems.sumOf { it.views }
+
+ TopAuthorsDataResult.Success(
+ TopAuthorsData(
+ authors = authorItems,
+ totalViews = totalViews
+ )
+ )
+ }
+ is WpRequestResult.WpError -> {
+ AppLog.e(
+ T.STATS,
+ "StatsDataSourceImpl: fetchTopAuthors WpError - ${result.errorMessage}"
+ )
+ TopAuthorsDataResult.Error(result.errorMessage)
+ }
+ is WpRequestResult.ResponseParsingError<*> -> {
+ AppLog.e(
+ T.STATS,
+ "StatsDataSourceImpl: fetchTopAuthors ResponseParsingError - $result"
+ )
+ TopAuthorsDataResult.Error("Response parsing error: $result")
+ }
+ else -> {
+ AppLog.e(
+ T.STATS,
+ "StatsDataSourceImpl: fetchTopAuthors unexpected result - $result"
+ )
+ TopAuthorsDataResult.Error("Unknown error: ${result::class.simpleName}")
+ }
+ }
+ }
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt
index 5432b2fd5a5a..66502b685d44 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt
@@ -240,13 +240,19 @@ private fun HeaderSection(
@Composable
private fun ColumnHeadersRow(cardType: StatsCardType) {
+ val headerResId = when (cardType) {
+ StatsCardType.MOST_VIEWED_POSTS_AND_PAGES -> R.string.stats_most_viewed_title_header
+ StatsCardType.MOST_VIEWED_REFERRERS -> R.string.stats_most_viewed_referrer_header
+ else -> cardType.displayNameResId
+ }
+
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text = stringResource(cardType.displayNameResId),
+ text = stringResource(headerResId),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
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 31d7c1b21fdf..a9859fbea341 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
@@ -10,6 +10,7 @@ import org.wordpress.android.ui.newstats.datasource.StatsDateRange
import org.wordpress.android.ui.newstats.datasource.StatsUnit
import org.wordpress.android.ui.newstats.datasource.StatsVisitsData
import org.wordpress.android.ui.newstats.datasource.StatsVisitsDataResult
+import org.wordpress.android.ui.newstats.datasource.TopAuthorsDataResult
import org.wordpress.android.ui.newstats.datasource.TopPostsDataResult
import org.wordpress.android.ui.newstats.mostviewed.MostViewedDataSource
import kotlinx.coroutines.withContext
@@ -735,6 +736,67 @@ class StatsRepository @Inject constructor(
}
}
}
+
+ /**
+ * Fetches top authors stats for a specific site and period with comparison data.
+ *
+ * @param siteId The WordPress.com site ID
+ * @param period The stats period to fetch
+ * @return Top authors data with comparison or error
+ */
+ suspend fun fetchTopAuthors(
+ siteId: Long,
+ period: StatsPeriod
+ ): TopAuthorsResult = withContext(ioDispatcher) {
+ val (currentDateRange, previousDateRange) = calculateComparisonDateRanges(period)
+
+ // Fetch both periods in parallel
+ val (currentResult, previousResult) = coroutineScope {
+ val currentDeferred = async { statsDataSource.fetchTopAuthors(siteId, currentDateRange, max = 0) }
+ val previousDeferred = async { statsDataSource.fetchTopAuthors(siteId, previousDateRange, max = 0) }
+ currentDeferred.await() to previousDeferred.await()
+ }
+
+ when (currentResult) {
+ is TopAuthorsDataResult.Success -> {
+ val previousAuthorsMap = if (previousResult is TopAuthorsDataResult.Success) {
+ previousResult.data.authors.associateBy { it.name }
+ } else {
+ emptyMap()
+ }
+
+ val totalViews = currentResult.data.authors.sumOf { it.views }
+ val previousTotalViews = if (previousResult is TopAuthorsDataResult.Success) {
+ previousResult.data.authors.sumOf { it.views }
+ } else {
+ 0L
+ }
+ val totalChange = totalViews - previousTotalViews
+ val totalChangePercent = if (previousTotalViews > 0) {
+ (totalChange.toDouble() / previousTotalViews.toDouble()) * PERCENTAGE_MULTIPLIER
+ } else if (totalViews > 0) PERCENTAGE_MULTIPLIER else PERCENTAGE_NO_CHANGE
+
+ TopAuthorsResult.Success(
+ authors = currentResult.data.authors.map { author ->
+ val previousViews = previousAuthorsMap[author.name]?.views ?: 0L
+ TopAuthorItemData(
+ name = author.name,
+ avatarUrl = author.avatarUrl,
+ views = author.views,
+ previousViews = previousViews
+ )
+ },
+ totalViews = totalViews,
+ totalViewsChange = totalChange,
+ totalViewsChangePercent = totalChangePercent
+ )
+ }
+ is TopAuthorsDataResult.Error -> {
+ appLogWrapper.e(AppLog.T.STATS, "Error fetching top authors: ${currentResult.message}")
+ TopAuthorsResult.Error(currentResult.message)
+ }
+ }
+ }
}
/**
@@ -900,3 +962,35 @@ data class CountryViewItemData(
PERCENTAGE_NO_CHANGE
}
}
+
+/**
+ * Result wrapper for top authors fetch operation.
+ */
+sealed class TopAuthorsResult {
+ data class Success(
+ val authors: List,
+ val totalViews: Long,
+ val totalViewsChange: Long,
+ val totalViewsChangePercent: Double
+ ) : TopAuthorsResult()
+ data class Error(val message: String) : TopAuthorsResult()
+}
+
+/**
+ * Data for a single top author item from the repository layer.
+ */
+data class TopAuthorItemData(
+ val name: String,
+ val avatarUrl: String?,
+ val views: Long,
+ val previousViews: Long
+) {
+ val viewsChange: Long get() = views - previousViews
+ val viewsChangePercent: Double get() = if (previousViews > 0) {
+ (viewsChange.toDouble() / previousViews.toDouble()) * PERCENTAGE_MULTIPLIER
+ } else if (views > 0) {
+ PERCENTAGE_MULTIPLIER
+ } else {
+ PERCENTAGE_NO_CHANGE
+ }
+}
diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml
index d530bc04d5b1..62af2e2cd658 100644
--- a/WordPress/src/main/res/values/strings.xml
+++ b/WordPress/src/main/res/values/strings.xml
@@ -1587,6 +1587,8 @@
Most Viewed
Posts & Pages
Referrers
+ Title
+ Referrer
Show All
Top %d
@@ -1595,6 +1597,10 @@
Locations
Views
+
+ Authors
+ Author
+
Remove Card
Move Card
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt
index 27394acd3b2b..19f3b5ad0119 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt
@@ -22,7 +22,8 @@ class StatsCardsConfigurationTest {
assertThat(hiddenCards).containsExactlyInAnyOrder(
StatsCardType.MOST_VIEWED_POSTS_AND_PAGES,
StatsCardType.MOST_VIEWED_REFERRERS,
- StatsCardType.COUNTRIES
+ StatsCardType.COUNTRIES,
+ StatsCardType.AUTHORS
)
}
@@ -34,7 +35,8 @@ class StatsCardsConfigurationTest {
StatsCardType.VIEWS_STATS,
StatsCardType.MOST_VIEWED_POSTS_AND_PAGES,
StatsCardType.MOST_VIEWED_REFERRERS,
- StatsCardType.COUNTRIES
+ StatsCardType.COUNTRIES,
+ StatsCardType.AUTHORS
)
)
@@ -54,7 +56,8 @@ class StatsCardsConfigurationTest {
StatsCardType.VIEWS_STATS,
StatsCardType.MOST_VIEWED_POSTS_AND_PAGES,
StatsCardType.MOST_VIEWED_REFERRERS,
- StatsCardType.COUNTRIES
+ StatsCardType.COUNTRIES,
+ StatsCardType.AUTHORS
)
}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt
new file mode 100644
index 000000000000..c6528e2512a0
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt
@@ -0,0 +1,474 @@
+package org.wordpress.android.ui.newstats.authors
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.R
+import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.ui.mysite.SelectedSiteRepository
+import org.wordpress.android.ui.newstats.StatsPeriod
+import org.wordpress.android.ui.newstats.components.StatsViewChange
+import org.wordpress.android.ui.newstats.repository.StatsRepository
+import org.wordpress.android.ui.newstats.repository.TopAuthorItemData
+import org.wordpress.android.ui.newstats.repository.TopAuthorsResult
+import org.wordpress.android.viewmodel.ResourceProvider
+
+@ExperimentalCoroutinesApi
+class AuthorsViewModelTest : BaseUnitTest() {
+ @Mock
+ private lateinit var selectedSiteRepository: SelectedSiteRepository
+
+ @Mock
+ private lateinit var accountStore: AccountStore
+
+ @Mock
+ private lateinit var statsRepository: StatsRepository
+
+ @Mock
+ private lateinit var resourceProvider: ResourceProvider
+
+ private lateinit var viewModel: AuthorsViewModel
+
+ private val testSite = SiteModel().apply {
+ id = 1
+ siteId = TEST_SITE_ID
+ name = "Test Site"
+ }
+
+ @Before
+ fun setUp() {
+ whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite)
+ whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN)
+ }
+
+ private fun initViewModel() {
+ viewModel = AuthorsViewModel(
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ resourceProvider
+ )
+ }
+
+ // region Error states
+ @Test
+ fun `when no site selected, then error state is emitted`() = test {
+ whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null)
+ whenever(resourceProvider.getString(R.string.stats_todays_stats_no_site_selected))
+ .thenReturn(NO_SITE_SELECTED_ERROR)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertThat(state).isInstanceOf(AuthorsCardUiState.Error::class.java)
+ assertThat((state as AuthorsCardUiState.Error).message).isEqualTo(NO_SITE_SELECTED_ERROR)
+ }
+
+ @Test
+ fun `when fetch fails, then error state is emitted`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(TopAuthorsResult.Error(ERROR_MESSAGE))
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertThat(state).isInstanceOf(AuthorsCardUiState.Error::class.java)
+ assertThat((state as AuthorsCardUiState.Error).message).isEqualTo(ERROR_MESSAGE)
+ }
+ // endregion
+
+ // region Success states
+ @Test
+ fun `when data loads successfully, then loaded state is emitted`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertThat(state).isInstanceOf(AuthorsCardUiState.Loaded::class.java)
+ }
+
+ @Test
+ fun `when data loads, then authors contain correct values`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.authors).hasSize(2)
+ assertThat(state.authors[0].name).isEqualTo(TEST_AUTHOR_NAME_1)
+ assertThat(state.authors[0].views).isEqualTo(TEST_AUTHOR_VIEWS_1)
+ assertThat(state.authors[0].avatarUrl).isEqualTo(TEST_AUTHOR_AVATAR_1)
+ assertThat(state.authors[1].name).isEqualTo(TEST_AUTHOR_NAME_2)
+ assertThat(state.authors[1].views).isEqualTo(TEST_AUTHOR_VIEWS_2)
+ }
+
+ @Test
+ fun `when data loads, then maxViewsForBar is set to first author views`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.maxViewsForBar).isEqualTo(TEST_AUTHOR_VIEWS_1)
+ }
+
+ @Test
+ fun `when data loads with more than 10 authors, then only 10 are shown in card`() = test {
+ val manyAuthors = (1..15).map { index ->
+ TopAuthorItemData(
+ name = "Author $index",
+ avatarUrl = "https://example.com/avatar$index.jpg",
+ views = (100 - index).toLong(),
+ previousViews = (90 - index).toLong()
+ )
+ }
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(
+ TopAuthorsResult.Success(
+ authors = manyAuthors,
+ totalViews = 1000,
+ totalViewsChange = 100,
+ totalViewsChangePercent = 10.0
+ )
+ )
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.authors).hasSize(10)
+ assertThat(state.hasMoreItems).isTrue()
+ }
+
+ @Test
+ fun `when data loads with 10 or fewer authors, then hasMoreItems is false`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.hasMoreItems).isFalse()
+ }
+
+ @Test
+ fun `when data loads with empty authors, then loaded state with empty list is emitted`() = test {
+ val emptyResult = TopAuthorsResult.Success(
+ authors = emptyList(),
+ totalViews = 0,
+ totalViewsChange = 0,
+ totalViewsChangePercent = 0.0
+ )
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(emptyResult)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.authors).isEmpty()
+ assertThat(state.maxViewsForBar).isEqualTo(0L)
+ assertThat(state.hasMoreItems).isFalse()
+ }
+ // endregion
+
+ // region Period changes
+ @Test
+ fun `when period changes, then data is reloaded`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ viewModel.onPeriodChanged(StatsPeriod.Last30Days)
+ advanceUntilIdle()
+
+ verify(statsRepository, times(2)).fetchTopAuthors(any(), any())
+ verify(statsRepository).fetchTopAuthors(eq(TEST_SITE_ID), eq(StatsPeriod.Last30Days))
+ }
+
+ @Test
+ fun `when same period is selected, then data is not reloaded`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ viewModel.onPeriodChanged(StatsPeriod.Last7Days)
+ advanceUntilIdle()
+
+ // Should only be called once during init
+ verify(statsRepository, times(1)).fetchTopAuthors(any(), any())
+ }
+ // endregion
+
+ // region Refresh
+ @Test
+ fun `when refresh is called, then isRefreshing becomes true then false`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ assertThat(viewModel.isRefreshing.value).isFalse()
+
+ viewModel.refresh()
+ advanceUntilIdle()
+
+ assertThat(viewModel.isRefreshing.value).isFalse()
+ }
+
+ @Test
+ fun `when refresh is called, then data is fetched`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ viewModel.refresh()
+ advanceUntilIdle()
+
+ // Called twice: once during init, once during refresh
+ verify(statsRepository, times(2)).fetchTopAuthors(eq(TEST_SITE_ID), any())
+ }
+
+ @Test
+ fun `when refresh is called with no site, then data is not fetched`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null)
+
+ viewModel.refresh()
+ advanceUntilIdle()
+
+ // Should only be called once during init
+ verify(statsRepository, times(1)).fetchTopAuthors(any(), any())
+ }
+ // endregion
+
+ // region Retry
+ @Test
+ fun `when onRetry is called, then data is reloaded`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ viewModel.onRetry()
+ advanceUntilIdle()
+
+ // Called twice: once during init, once during retry
+ verify(statsRepository, times(2)).fetchTopAuthors(any(), any())
+ }
+ // endregion
+
+ // region getDetailData
+ @Test
+ fun `when getDetailData is called, then returns cached data`() = test {
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(createSuccessResult())
+ whenever(resourceProvider.getString(R.string.stats_period_last_7_days))
+ .thenReturn("Last 7 days")
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val detailData = viewModel.getDetailData()
+
+ assertThat(detailData.authors).hasSize(2)
+ assertThat(detailData.totalViews).isEqualTo(TEST_TOTAL_VIEWS)
+ assertThat(detailData.totalViewsChange).isEqualTo(TEST_TOTAL_VIEWS_CHANGE)
+ assertThat(detailData.totalViewsChangePercent).isEqualTo(TEST_TOTAL_VIEWS_CHANGE_PERCENT)
+ assertThat(detailData.dateRange).isEqualTo("Last 7 days")
+ }
+
+ @Test
+ fun `when getDetailData is called, then all authors are returned not just card items`() = test {
+ val manyAuthors = (1..15).map { index ->
+ TopAuthorItemData(
+ name = "Author $index",
+ avatarUrl = "https://example.com/avatar$index.jpg",
+ views = (100 - index).toLong(),
+ previousViews = (90 - index).toLong()
+ )
+ }
+ whenever(resourceProvider.getString(R.string.stats_period_last_7_days))
+ .thenReturn("Last 7 days")
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(
+ TopAuthorsResult.Success(
+ authors = manyAuthors,
+ totalViews = 1000,
+ totalViewsChange = 100,
+ totalViewsChangePercent = 10.0
+ )
+ )
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val detailData = viewModel.getDetailData()
+ // Card shows max 10, but detail data should have all 15
+ assertThat(detailData.authors).hasSize(15)
+ }
+ // endregion
+
+ // region Change calculations
+ @Test
+ fun `when author has positive change, then StatsViewChange_Positive is returned`() = test {
+ val authors = listOf(
+ TopAuthorItemData(
+ name = "Author 1",
+ avatarUrl = null,
+ views = 150,
+ previousViews = 100
+ )
+ )
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(
+ TopAuthorsResult.Success(
+ authors = authors,
+ totalViews = 150,
+ totalViewsChange = 50,
+ totalViewsChangePercent = 50.0
+ )
+ )
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.authors[0].change).isInstanceOf(StatsViewChange.Positive::class.java)
+ val change = state.authors[0].change as StatsViewChange.Positive
+ assertThat(change.value).isEqualTo(50)
+ assertThat(change.percentage).isEqualTo(50.0)
+ }
+
+ @Test
+ fun `when author has negative change, then StatsViewChange_Negative is returned`() = test {
+ val authors = listOf(
+ TopAuthorItemData(
+ name = "Author 1",
+ avatarUrl = null,
+ views = 50,
+ previousViews = 100
+ )
+ )
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(
+ TopAuthorsResult.Success(
+ authors = authors,
+ totalViews = 50,
+ totalViewsChange = -50,
+ totalViewsChangePercent = -50.0
+ )
+ )
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.authors[0].change).isInstanceOf(StatsViewChange.Negative::class.java)
+ val change = state.authors[0].change as StatsViewChange.Negative
+ assertThat(change.value).isEqualTo(50)
+ assertThat(change.percentage).isEqualTo(50.0)
+ }
+
+ @Test
+ fun `when author has no change, then StatsViewChange_NoChange is returned`() = test {
+ val authors = listOf(
+ TopAuthorItemData(
+ name = "Author 1",
+ avatarUrl = null,
+ views = 100,
+ previousViews = 100
+ )
+ )
+ whenever(statsRepository.fetchTopAuthors(any(), any()))
+ .thenReturn(
+ TopAuthorsResult.Success(
+ authors = authors,
+ totalViews = 100,
+ totalViewsChange = 0,
+ totalViewsChangePercent = 0.0
+ )
+ )
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value as AuthorsCardUiState.Loaded
+ assertThat(state.authors[0].change).isEqualTo(StatsViewChange.NoChange)
+ }
+ // endregion
+
+ // region Helper functions
+ private fun createSuccessResult() = TopAuthorsResult.Success(
+ authors = listOf(
+ TopAuthorItemData(
+ name = TEST_AUTHOR_NAME_1,
+ avatarUrl = TEST_AUTHOR_AVATAR_1,
+ views = TEST_AUTHOR_VIEWS_1,
+ previousViews = TEST_AUTHOR_PREVIOUS_VIEWS_1
+ ),
+ TopAuthorItemData(
+ name = TEST_AUTHOR_NAME_2,
+ avatarUrl = TEST_AUTHOR_AVATAR_2,
+ views = TEST_AUTHOR_VIEWS_2,
+ previousViews = TEST_AUTHOR_PREVIOUS_VIEWS_2
+ )
+ ),
+ totalViews = TEST_TOTAL_VIEWS,
+ totalViewsChange = TEST_TOTAL_VIEWS_CHANGE,
+ totalViewsChangePercent = TEST_TOTAL_VIEWS_CHANGE_PERCENT
+ )
+ // endregion
+
+ companion object {
+ private const val TEST_SITE_ID = 123L
+ private const val TEST_ACCESS_TOKEN = "test_access_token"
+ private const val ERROR_MESSAGE = "Network error"
+ private const val NO_SITE_SELECTED_ERROR = "No site selected"
+
+ private const val TEST_AUTHOR_NAME_1 = "John Doe"
+ private const val TEST_AUTHOR_NAME_2 = "Jane Smith"
+ private const val TEST_AUTHOR_AVATAR_1 = "https://example.com/avatar1.jpg"
+ private const val TEST_AUTHOR_AVATAR_2 = "https://example.com/avatar2.jpg"
+ private const val TEST_AUTHOR_VIEWS_1 = 500L
+ private const val TEST_AUTHOR_VIEWS_2 = 300L
+ private const val TEST_AUTHOR_PREVIOUS_VIEWS_1 = 400L
+ private const val TEST_AUTHOR_PREVIOUS_VIEWS_2 = 250L
+
+ private const val TEST_TOTAL_VIEWS = 800L
+ private const val TEST_TOTAL_VIEWS_CHANGE = 150L
+ private const val TEST_TOTAL_VIEWS_CHANGE_PERCENT = 23.1
+ }
+}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/countries/CountriesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/countries/CountriesViewModelTest.kt
index 7e84b539f1d7..09682691fb79 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/countries/CountriesViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/countries/CountriesViewModelTest.kt
@@ -62,13 +62,15 @@ class CountriesViewModelTest : BaseUnitTest() {
@Test
fun `when no site selected, then error state is emitted`() = test {
whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null)
+ whenever(resourceProvider.getString(R.string.stats_todays_stats_no_site_selected))
+ .thenReturn(NO_SITE_SELECTED_ERROR)
initViewModel()
advanceUntilIdle()
val state = viewModel.uiState.value
assertThat(state).isInstanceOf(CountriesCardUiState.Error::class.java)
- assertThat((state as CountriesCardUiState.Error).message).isEqualTo("No site selected")
+ assertThat((state as CountriesCardUiState.Error).message).isEqualTo(NO_SITE_SELECTED_ERROR)
}
@Test
@@ -524,6 +526,7 @@ class CountriesViewModelTest : BaseUnitTest() {
private const val TEST_SITE_ID = 123L
private const val TEST_ACCESS_TOKEN = "test_access_token"
private const val ERROR_MESSAGE = "Network error"
+ private const val NO_SITE_SELECTED_ERROR = "No site selected"
private const val TEST_COUNTRY_CODE_1 = "US"
private const val TEST_COUNTRY_CODE_2 = "UK"
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryAuthorsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryAuthorsTest.kt
new file mode 100644
index 000000000000..18e647bb1dcf
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryAuthorsTest.kt
@@ -0,0 +1,240 @@
+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.times
+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.StatsPeriod
+import org.wordpress.android.ui.newstats.datasource.StatsDataSource
+import org.wordpress.android.ui.newstats.datasource.TopAuthorItem
+import org.wordpress.android.ui.newstats.datasource.TopAuthorsData
+import org.wordpress.android.ui.newstats.datasource.TopAuthorsDataResult
+
+@ExperimentalCoroutinesApi
+class StatsRepositoryAuthorsTest : 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 fetchTopAuthors, then success result is returned`() = test {
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(createTopAuthorsData()))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Success::class.java)
+ val success = result as TopAuthorsResult.Success
+ assertThat(success.authors).hasSize(2)
+ assertThat(success.authors[0].name).isEqualTo(TEST_AUTHOR_NAME_1)
+ assertThat(success.authors[0].avatarUrl).isEqualTo(TEST_AUTHOR_AVATAR_1)
+ assertThat(success.authors[0].views).isEqualTo(TEST_AUTHOR_VIEWS_1)
+ assertThat(success.authors[1].name).isEqualTo(TEST_AUTHOR_NAME_2)
+ assertThat(success.authors[1].views).isEqualTo(TEST_AUTHOR_VIEWS_2)
+ }
+
+ @Test
+ fun `given successful response, when fetchTopAuthors, then totalViews is sum of author views`() = test {
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(createTopAuthorsData()))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Success::class.java)
+ val success = result as TopAuthorsResult.Success
+ assertThat(success.totalViews).isEqualTo(TEST_AUTHOR_VIEWS_1 + TEST_AUTHOR_VIEWS_2)
+ }
+
+ @Test
+ fun `given current and previous data, when fetchTopAuthors, then change is calculated correctly`() = test {
+ val currentData = TopAuthorsData(
+ authors = listOf(
+ TopAuthorItem("Author 1", "https://example.com/a1.jpg", 150),
+ TopAuthorItem("Author 2", "https://example.com/a2.jpg", 100)
+ ),
+ totalViews = 250L
+ )
+ val previousData = TopAuthorsData(
+ authors = listOf(
+ TopAuthorItem("Author 1", "https://example.com/a1.jpg", 100),
+ TopAuthorItem("Author 2", "https://example.com/a2.jpg", 100)
+ ),
+ totalViews = 200L
+ )
+
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(currentData))
+ .thenReturn(TopAuthorsDataResult.Success(previousData))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Success::class.java)
+ val success = result as TopAuthorsResult.Success
+ // Current total: 250, Previous total: 200, Change: 50
+ assertThat(success.totalViews).isEqualTo(250)
+ assertThat(success.totalViewsChange).isEqualTo(50)
+ assertThat(success.totalViewsChangePercent).isEqualTo(25.0)
+ }
+
+ @Test
+ fun `given author in both periods, when fetchTopAuthors, then previousViews is set correctly`() = test {
+ val currentData = TopAuthorsData(
+ authors = listOf(TopAuthorItem("Author 1", null, 150)),
+ totalViews = 150L
+ )
+ val previousData = TopAuthorsData(
+ authors = listOf(TopAuthorItem("Author 1", null, 100)),
+ totalViews = 100L
+ )
+
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(currentData))
+ .thenReturn(TopAuthorsDataResult.Success(previousData))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Success::class.java)
+ val success = result as TopAuthorsResult.Success
+ assertThat(success.authors[0].previousViews).isEqualTo(100)
+ assertThat(success.authors[0].viewsChange).isEqualTo(50)
+ assertThat(success.authors[0].viewsChangePercent).isEqualTo(50.0)
+ }
+
+ @Test
+ fun `given new author not in previous period, when fetchTopAuthors, then previousViews is zero`() = test {
+ val currentData = TopAuthorsData(
+ authors = listOf(TopAuthorItem("New Author", null, 100)),
+ totalViews = 100L
+ )
+ val previousData = TopAuthorsData(
+ authors = emptyList(),
+ totalViews = 0L
+ )
+
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(currentData))
+ .thenReturn(TopAuthorsDataResult.Success(previousData))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Success::class.java)
+ val success = result as TopAuthorsResult.Success
+ assertThat(success.authors[0].previousViews).isEqualTo(0)
+ assertThat(success.authors[0].viewsChange).isEqualTo(100)
+ assertThat(success.authors[0].viewsChangePercent).isEqualTo(100.0)
+ }
+
+ @Test
+ fun `given previous fetch fails, when fetchTopAuthors, then previousViews defaults to zero`() = test {
+ val currentData = TopAuthorsData(
+ authors = listOf(TopAuthorItem("Author 1", null, 100)),
+ totalViews = 100L
+ )
+
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(currentData))
+ .thenReturn(TopAuthorsDataResult.Error("Network error"))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Success::class.java)
+ val success = result as TopAuthorsResult.Success
+ assertThat(success.authors[0].previousViews).isEqualTo(0)
+ assertThat(success.totalViewsChange).isEqualTo(100)
+ assertThat(success.totalViewsChangePercent).isEqualTo(100.0)
+ }
+
+ @Test
+ fun `given error response, when fetchTopAuthors, then error result is returned`() = test {
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Error(ERROR_MESSAGE))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Error::class.java)
+ assertThat((result as TopAuthorsResult.Error).message).isEqualTo(ERROR_MESSAGE)
+ }
+
+ @Test
+ fun `when fetchTopAuthors is called, then data source is called twice for comparison`() = test {
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(createTopAuthorsData()))
+
+ repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ // Verify data source is called twice (current and previous period) with no limit
+ verify(statsDataSource, times(2)).fetchTopAuthors(
+ siteId = eq(TEST_SITE_ID),
+ dateRange = any(),
+ max = eq(0)
+ )
+ }
+
+ @Test
+ fun `given empty authors list, when fetchTopAuthors, then success with empty list is returned`() = test {
+ val emptyData = TopAuthorsData(
+ authors = emptyList(),
+ totalViews = 0L
+ )
+ whenever(statsDataSource.fetchTopAuthors(any(), any(), any()))
+ .thenReturn(TopAuthorsDataResult.Success(emptyData))
+
+ val result = repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days)
+
+ assertThat(result).isInstanceOf(TopAuthorsResult.Success::class.java)
+ val success = result as TopAuthorsResult.Success
+ assertThat(success.authors).isEmpty()
+ assertThat(success.totalViews).isEqualTo(0)
+ assertThat(success.totalViewsChange).isEqualTo(0)
+ assertThat(success.totalViewsChangePercent).isEqualTo(0.0)
+ }
+
+ private fun createTopAuthorsData() = TopAuthorsData(
+ authors = listOf(
+ TopAuthorItem(
+ name = TEST_AUTHOR_NAME_1,
+ avatarUrl = TEST_AUTHOR_AVATAR_1,
+ views = TEST_AUTHOR_VIEWS_1
+ ),
+ TopAuthorItem(
+ name = TEST_AUTHOR_NAME_2,
+ avatarUrl = TEST_AUTHOR_AVATAR_2,
+ views = TEST_AUTHOR_VIEWS_2
+ )
+ ),
+ totalViews = TEST_AUTHOR_VIEWS_1 + TEST_AUTHOR_VIEWS_2
+ )
+
+ companion object {
+ private const val TEST_SITE_ID = 123L
+ private const val ERROR_MESSAGE = "Test error message"
+
+ private const val TEST_AUTHOR_NAME_1 = "John Doe"
+ private const val TEST_AUTHOR_NAME_2 = "Jane Smith"
+ private const val TEST_AUTHOR_AVATAR_1 = "https://example.com/avatar1.jpg"
+ private const val TEST_AUTHOR_AVATAR_2 = "https://example.com/avatar2.jpg"
+ private const val TEST_AUTHOR_VIEWS_1 = 500L
+ private const val TEST_AUTHOR_VIEWS_2 = 300L
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index aa31de66b984..0a550d9defc9 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-f22a59cb660208f8706b7a2dcc1a9324567a92e4'
+wordpress-rs = 'trunk-9edcee430afd18d7d440baf497a763f9b1bb83d9'
wordpress-utils = '3.14.0'
automattic-ucrop = '2.2.11'
zendesk = '5.5.2'