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'