From 86c8c3366cc0e776bb40e5767cacd4fd5a779e54 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 14:53:02 +0100 Subject: [PATCH 01/18] Using the new WPComLanguage --- .../datasource/StatsDataSourceImpl.kt | 19 ++++++++++++------- gradle/libs.versions.toml | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) 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 9fda0e911781..56c91e261680 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 @@ -12,8 +12,10 @@ 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 import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T +import rs.wordpress.api.kotlin.fromLocale import javax.inject.Inject /** @@ -51,7 +53,7 @@ class StatsDataSourceImpl @Inject constructor( unit = unit.toApiUnit(), quantity = quantity.toUInt(), endDate = endDate, - locale = localeManagerWrapper.getLocale().toString() + locale = wpComLanguage ) val result = wpComApiClient.request { requestBuilder -> @@ -110,20 +112,23 @@ class StatsDataSourceImpl @Inject constructor( StatsUnit.MONTH -> StatsVisitsUnit.MONTH } + private val wpComLanguage: WpComLanguage? + get() = WpComLanguage.fromLocale(localeManagerWrapper.getLocale()) + private fun buildTopPostsParams(dateRange: StatsDateRange, max: Int) = when (dateRange) { is StatsDateRange.Preset -> StatsTopPostsParams( period = StatsTopPostsPeriod.DAY, date = dateRange.date, num = dateRange.num.toUInt(), max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString() + locale = wpComLanguage ) is StatsDateRange.Custom -> StatsTopPostsParams( period = StatsTopPostsPeriod.DAY, date = dateRange.date, startDate = dateRange.startDate, max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString() + locale = wpComLanguage ) } @@ -192,14 +197,14 @@ class StatsDataSourceImpl @Inject constructor( date = dateRange.date, num = dateRange.num.toUInt(), max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString() + locale = wpComLanguage ) is StatsDateRange.Custom -> StatsReferrersParams( period = StatsReferrersPeriod.DAY, date = dateRange.date, startDate = dateRange.startDate, max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString() + locale = wpComLanguage ) } @@ -246,7 +251,7 @@ class StatsDataSourceImpl @Inject constructor( date = dateRange.date, num = dateRange.num.toUInt(), max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString(), + locale = wpComLanguage, summarize = true ) is StatsDateRange.Custom -> StatsCountryViewsParams( @@ -254,7 +259,7 @@ class StatsDataSourceImpl @Inject constructor( date = dateRange.date, startDate = dateRange.startDate, max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString(), + locale = wpComLanguage, summarize = true ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c526368c022f..b7782ab41d5a 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 = '1134-6cdddac9b99642742ae89bb5833bbe2ed1c7cdd1' +wordpress-rs = '1151-b0c0d6afcbbcd406589074a073b64513468247b1' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2' From cc793fb5a9790a524f944c69bdfad5d69a999e4f Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 15:34:14 +0100 Subject: [PATCH 02/18] Add authors card --- WordPress/src/main/AndroidManifest.xml | 5 + .../android/ui/newstats/NewStatsActivity.kt | 35 +- .../android/ui/newstats/StatsCardType.kt | 6 +- .../ui/newstats/components/StatsViewChange.kt | 55 +++ .../ui/newstats/datasource/StatsDataSource.kt | 39 ++ .../datasource/StatsDataSourceImpl.kt | 71 ++- .../ui/newstats/repository/StatsRepository.kt | 94 ++++ .../ui/newstats/topauthors/TopAuthorsCard.kt | 425 ++++++++++++++++++ .../topauthors/TopAuthorsCardUiState.kt | 36 ++ .../topauthors/TopAuthorsDetailActivity.kt | 314 +++++++++++++ .../topauthors/TopAuthorsViewModel.kt | 166 +++++++ WordPress/src/main/res/values/strings.xml | 4 + 12 files changed, 1245 insertions(+), 5 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsViewChange.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModel.kt diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 981df4ce2d2b..ddae21280eb5 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" /> + + TopAuthorsCard( + uiState = topAuthorsUiState, + onShowAllClick = { + val detailData = topAuthorsViewModel.getDetailData() + TopAuthorsDetailActivity.start( + context = context, + authors = detailData.authors, + totalViews = detailData.totalViews, + totalViewsChange = detailData.totalViewsChange, + totalViewsChangePercent = detailData.totalViewsChangePercent, + dateRange = detailData.dateRange + ) + }, + onRetry = topAuthorsViewModel::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/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/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 56c91e261680..2b46a3e45b58 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,6 +4,9 @@ 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 org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity import uniffi.wp_api.StatsReferrersParams import uniffi.wp_api.StatsReferrersPeriod import uniffi.wp_api.StatsTopPostsParams @@ -13,6 +16,9 @@ import uniffi.wp_api.StatsCountryViewsPeriod import uniffi.wp_api.StatsVisitsParams import uniffi.wp_api.StatsVisitsUnit import uniffi.wp_api.WpComLanguage +import java.time.LocalDate +import java.time.ZoneId +import java.util.Date import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import rs.wordpress.api.kotlin.fromLocale @@ -24,7 +30,8 @@ import javax.inject.Inject */ class StatsDataSourceImpl @Inject constructor( private val wpComApiClientProvider: WpComApiClientProvider, - private val localeManagerWrapper: LocaleManagerWrapper + private val localeManagerWrapper: LocaleManagerWrapper, + private val authorsRestClient: AuthorsRestClient ) : StatsDataSource { /** * Access token for API authentication. @@ -318,4 +325,66 @@ class StatsDataSourceImpl @Inject constructor( } } } + + override suspend fun fetchTopAuthors( + siteId: Long, + dateRange: StatsDateRange, + max: Int + ): TopAuthorsDataResult { + val date = when (dateRange) { + is StatsDateRange.Preset -> LocalDate.parse(dateRange.date) + is StatsDateRange.Custom -> LocalDate.parse(dateRange.date) + } + val javaDate = Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant()) + + // Create a minimal SiteModel with just the siteId + val site = SiteModel().apply { this.siteId = siteId } + + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchTopAuthors - siteId=$siteId, date=$date, max=$max") + + return try { + val result = authorsRestClient.fetchAuthors( + site = site, + granularity = StatsGranularity.DAYS, + date = javaDate, + itemsToLoad = max, + forced = true + ) + + if (result.isError) { + val errorMessage = result.error?.message ?: "Unknown error" + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchTopAuthors error - $errorMessage") + TopAuthorsDataResult.Error(errorMessage) + } else { + val response = result.response + // Get the first (and typically only) group of authors + val authorsGroup = response?.groups?.values?.firstOrNull() + val authorsList = authorsGroup?.authors.orEmpty() + + val authorItems = authorsList.map { author -> + TopAuthorItem( + name = author.name.orEmpty(), + avatarUrl = author.avatarUrl, + views = author.views?.toLong() ?: 0L + ) + } + + val totalViews = authorItems.sumOf { it.views } + + AppLog.d( + T.STATS, + "StatsDataSourceImpl: fetchTopAuthors success - ${authorItems.size} authors" + ) + TopAuthorsDataResult.Success( + TopAuthorsData( + authors = authorItems, + totalViews = totalViews + ) + ) + } + } catch (e: Exception) { + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchTopAuthors exception - ${e.message}") + TopAuthorsDataResult.Error(e.message ?: "Unknown error") + } + } } 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..c998b011aaf1 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) } + val previousDeferred = async { statsDataSource.fetchTopAuthors(siteId, previousDateRange) } + 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/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt new file mode 100644 index 000000000000..eab48886c231 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt @@ -0,0 +1,425 @@ +package org.wordpress.android.ui.newstats.topauthors + +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.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Person +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 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.StatsChangeIndicator +import org.wordpress.android.ui.newstats.components.StatsViewChange +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 LOADING_ITEM_COUNT = 4 + +@Composable +fun TopAuthorsCard( + uiState: TopAuthorsCardUiState, + onShowAllClick: () -> Unit, + onRetry: () -> Unit, + onRemoveCard: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + val borderColor = MaterialTheme.colorScheme.outlineVariant + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = CardMargin, vertical = 8.dp) + .clip(RoundedCornerShape(CardCornerRadius)) + .border(width = 1.dp, color = borderColor, shape = RoundedCornerShape(CardCornerRadius)) + .background(MaterialTheme.colorScheme.surface) + ) { + when (uiState) { + is TopAuthorsCardUiState.Loading -> LoadingContent() + is TopAuthorsCardUiState.Loaded -> LoadedContent( + uiState, onShowAllClick, onRemoveCard, + cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom + ) + is TopAuthorsCardUiState.Error -> ErrorContent( + uiState, onRetry, onRemoveCard, + cardPosition, onMoveUp, onMoveToTop, onMoveDown, 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: TopAuthorsCardUiState.Loaded, + onShowAllClick: () -> 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_authors_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(8.dp)) + + if (state.authors.isEmpty()) { + EmptyContent() + } else { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.stats_authors_author_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 + ) + } + 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 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 AuthorRow( + author: TopAuthorUiItem, + 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 + ) { + // Avatar (circular) + if (author.avatarUrl != null) { + AsyncImage( + model = author.avatarUrl, + contentDescription = author.name, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + } else { + Box( + modifier = Modifier + .size(40.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + + // Author name + Text( + text = author.name, + 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(author.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + StatsChangeIndicator(change = author.change) + } + } + } +} + +@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 + ) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun ErrorContent( + state: TopAuthorsCardUiState.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_authors_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 +private fun TopAuthorsCardLoadingPreview() { + AppThemeM3 { + TopAuthorsCard( + uiState = TopAuthorsCardUiState.Loading, + onShowAllClick = {}, + onRetry = {}, + onRemoveCard = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TopAuthorsCardLoadedPreview() { + AppThemeM3 { + TopAuthorsCard( + uiState = TopAuthorsCardUiState.Loaded( + authors = listOf( + TopAuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)), + TopAuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)), + TopAuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)), + TopAuthorUiItem("Alice Brown", null, 485, StatsViewChange.NoChange) + ), + maxViewsForBar = 3464, + hasMoreItems = true + ), + onShowAllClick = {}, + onRetry = {}, + onRemoveCard = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TopAuthorsCardErrorPreview() { + AppThemeM3 { + TopAuthorsCard( + uiState = TopAuthorsCardUiState.Error("Failed to load author data"), + onShowAllClick = {}, + onRetry = {}, + onRemoveCard = {} + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCardUiState.kt new file mode 100644 index 000000000000..063df9dfb0d8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCardUiState.kt @@ -0,0 +1,36 @@ +package org.wordpress.android.ui.newstats.topauthors + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.wordpress.android.ui.newstats.components.StatsViewChange + +/** + * UI State for the Top Authors stats card. + */ +sealed class TopAuthorsCardUiState { + data object Loading : TopAuthorsCardUiState() + + data class Loaded( + val authors: List, + val maxViewsForBar: Long, + val hasMoreItems: Boolean + ) : TopAuthorsCardUiState() + + data class Error(val message: String) : TopAuthorsCardUiState() +} + +/** + * A single author item in the top 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 TopAuthorUiItem( + 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/topauthors/TopAuthorsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt new file mode 100644 index 000000000000..3cdd3a4b29f1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt @@ -0,0 +1,314 @@ +package org.wordpress.android.ui.newstats.topauthors + +import android.content.Context +import android.content.Intent +import android.os.Bundle +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.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.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Person +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 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.StatsChangeIndicator +import org.wordpress.android.ui.newstats.components.StatsSummaryCard +import org.wordpress.android.ui.newstats.components.StatsViewChange +import org.wordpress.android.ui.newstats.util.formatStatValue +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 TopAuthorsDetailActivity : 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 ?: 1L + + setContent { + AppThemeM3 { + TopAuthorsDetailScreen( + 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, TopAuthorsDetailActivity::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 TopAuthorsDetailScreen( + 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)) + // Summary card + StatsSummaryCard( + totalViews = totalViews, + dateRange = dateRange, + totalViewsChange = totalViewsChange, + totalViewsChangePercent = totalViewsChangePercent + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + item { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.stats_authors_author_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 + ) + } + 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: TopAuthorUiItem, + 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) + ) + + // Avatar (circular) + if (author.avatarUrl != null) { + AsyncImage( + model = author.avatarUrl, + contentDescription = author.name, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + } else { + Box( + modifier = Modifier + .size(40.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + + // Author name + Text( + text = author.name, + 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(author.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + StatsChangeIndicator(change = author.change) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TopAuthorsDetailScreenPreview() { + AppThemeM3 { + TopAuthorsDetailScreen( + authors = listOf( + TopAuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)), + TopAuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)), + TopAuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)), + TopAuthorUiItem("Alice Brown", null, 485, StatsViewChange.Positive(33, 7.3)), + TopAuthorUiItem("Charlie Wilson", null, 412, StatsViewChange.NoChange), + TopAuthorUiItem("Diana Miller", null, 387, StatsViewChange.Negative(8, 2.0)), + TopAuthorUiItem("Edward Davis", null, 298, StatsViewChange.Positive(21, 7.6)), + TopAuthorUiItem("Fiona Garcia", null, 245, StatsViewChange.Positive(15, 6.5)), + TopAuthorUiItem("George Martinez", null, 201, StatsViewChange.Negative(5, 2.4)), + TopAuthorUiItem("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/topauthors/TopAuthorsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModel.kt new file mode 100644 index 000000000000..42b330e97a6a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModel.kt @@ -0,0 +1,166 @@ +package org.wordpress.android.ui.newstats.topauthors + +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.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 TopAuthorsViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = MutableStateFlow(TopAuthorsCardUiState.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() { + viewModelScope.launch { + _uiState.value = TopAuthorsCardUiState.Loading + + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + _uiState.value = TopAuthorsCardUiState.Error("No site selected") + return@launch + } + + initializeRepository() + fetchTopAuthors(site) + } + } + + fun refresh() { + viewModelScope.launch { + _isRefreshing.value = true + + val site = selectedSiteRepository.getSelectedSite() + if (site != null) { + initializeRepository() + fetchTopAuthors(site) + } + + _isRefreshing.value = false + } + } + + fun onRetry() { + loadData() + } + + fun onPeriodChanged(period: StatsPeriod) { + if (currentPeriod != period) { + currentPeriod = period + loadData() + } + } + + fun getDetailData(): TopAuthorsDetailData { + return TopAuthorsDetailData( + authors = allAuthors, + totalViews = cachedTotalViews, + totalViewsChange = cachedTotalViewsChange, + totalViewsChangePercent = cachedTotalViewsChangePercent, + dateRange = currentPeriod.toDateRangeString(resourceProvider) + ) + } + + private fun initializeRepository() { + accountStore.accessToken?.let { token -> + statsRepository.init(token) + } + } + + 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 = TopAuthorsCardUiState.Loaded( + authors = emptyList(), + maxViewsForBar = 0, + hasMoreItems = false + ) + } else { + val authors = result.authors.map { author -> + TopAuthorUiItem( + 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 ?: 1L + + _uiState.value = TopAuthorsCardUiState.Loaded( + authors = cardAuthors, + maxViewsForBar = maxViewsForBar, + hasMoreItems = authors.size > CARD_MAX_ITEMS + ) + } + } + is TopAuthorsResult.Error -> { + _uiState.value = TopAuthorsCardUiState.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 TopAuthorsDetailData( + val authors: List, + val totalViews: Long, + val totalViewsChange: Long, + val totalViewsChangePercent: Double, + val dateRange: String +) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index ce6be4dd1114..efc152843451 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1582,6 +1582,10 @@ Locations Views + + Authors + Author + Remove Card Move Card From 63cfdcd5eb522026b2388d8927e920847cb36300 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 15:39:56 +0100 Subject: [PATCH 03/18] Title fix --- .../android/ui/newstats/mostviewed/MostViewedCard.kt | 8 +++++++- WordPress/src/main/res/values/strings.xml | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index efc152843451..e98900c5198e 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1574,6 +1574,8 @@ Most Viewed Posts & Pages Referrers + Title + Referrer Show All Top %d From 61df0ac8576f82d2472351f30d495a54c747ae92 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 16:35:53 +0100 Subject: [PATCH 04/18] Ading tests --- .../newstats/StatsCardsConfigurationTest.kt | 9 +- .../StatsRepositoryTopAuthorsTest.kt | 240 +++++++++ .../topauthors/TopAuthorsViewModelTest.kt | 471 ++++++++++++++++++ 3 files changed, 717 insertions(+), 3 deletions(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTopAuthorsTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModelTest.kt 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/repository/StatsRepositoryTopAuthorsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTopAuthorsTest.kt new file mode 100644 index 000000000000..3b907dd67094 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTopAuthorsTest.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 StatsRepositoryTopAuthorsTest : 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) + verify(statsDataSource, times(2)).fetchTopAuthors( + siteId = eq(TEST_SITE_ID), + dateRange = any(), + max = any() + ) + } + + @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/WordPress/src/test/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModelTest.kt new file mode 100644 index 000000000000..2e8cd951bc8b --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModelTest.kt @@ -0,0 +1,471 @@ +package org.wordpress.android.ui.newstats.topauthors + +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 TopAuthorsViewModelTest : 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: TopAuthorsViewModel + + 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 = TopAuthorsViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + } + + // region Error states + @Test + fun `when no site selected, then error state is emitted`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TopAuthorsCardUiState.Error::class.java) + assertThat((state as TopAuthorsCardUiState.Error).message).isEqualTo("No site selected") + } + + @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(TopAuthorsCardUiState.Error::class.java) + assertThat((state as TopAuthorsCardUiState.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(TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 TopAuthorsCardUiState.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 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 + } +} From e3d35b4914440fe125a33b78810f982b62e9ed29 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 16:37:03 +0100 Subject: [PATCH 05/18] detekt --- .../android/ui/newstats/datasource/StatsDataSourceImpl.kt | 1 + 1 file changed, 1 insertion(+) 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 2b46a3e45b58..00cefd583db3 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 @@ -326,6 +326,7 @@ class StatsDataSourceImpl @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") override suspend fun fetchTopAuthors( siteId: Long, dateRange: StatsDateRange, From 15211f86dcbedf61c89b7dd7b07d1c11dcc94433 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 16:48:15 +0100 Subject: [PATCH 06/18] Extracting common code --- .../ui/newstats/components/StatsCardCommon.kt | 301 ++++++++++++++++++ .../ui/newstats/countries/CountriesCard.kt | 260 +++------------ .../countries/CountriesCardUiState.kt | 10 + .../countries/CountriesDetailActivity.kt | 123 +------ .../ui/newstats/topauthors/TopAuthorsCard.kt | 277 ++++------------ .../topauthors/TopAuthorsDetailActivity.kt | 118 +------ 6 files changed, 445 insertions(+), 644 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt 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..f2081067c2d8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt @@ -0,0 +1,301 @@ +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) + ) +} 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..aca162ff4645 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,29 @@ 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 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.StatsItemName +import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.components.StatsViewsColumn 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 +51,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 +141,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 +171,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 +192,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 +208,43 @@ 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 + StatsListRowContainer(percentage = percentage) { 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) - ) - ) - } + CountryFlag(flagIconUrl = country.flagIconUrl, countryName = country.countryName) 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) - ) + StatsItemName(name = country.countryName, 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) - } + StatsViewsColumn(views = country.views, change = country.change.toStatsViewChange()) } } } @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 +) { + if (flagIconUrl != null) { + AsyncImage( + model = flagIconUrl, + contentDescription = countryName, + modifier = modifier.size(24.dp) ) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurface + } else { + Box( + modifier = modifier + .size(24.dp) + .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..3a6c2cba90bc 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,44 @@ 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.StatsItemName +import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.components.StatsPositionNumber import org.wordpress.android.ui.newstats.components.StatsSummaryCard -import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.ui.newstats.components.StatsViewsColumn 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" @@ -180,7 +171,6 @@ private fun CountriesDetailScreen( ) { item { Spacer(modifier = Modifier.height(8.dp)) - // Summary card StatsSummaryCard( totalViews = totalViews, dateRange = dateRange, @@ -190,7 +180,7 @@ private fun CountriesDetailScreen( Spacer(modifier = Modifier.height(16.dp)) // Map - CountryMap( + StatsGeoChartWebView( mapData = mapData, modifier = Modifier .fillMaxWidth() @@ -203,24 +193,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,94 +219,25 @@ 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) - ) - + StatsListRowContainer(percentage = percentage) { 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) - ) - ) - } + StatsPositionNumber(position = position) + CountryFlag(flagIconUrl = country.flagIconUrl, countryName = country.countryName) 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) - ) + StatsItemName(name = country.countryName, 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) - } + StatsViewsColumn(views = country.views, change = country.change.toStatsViewChange()) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt index eab48886c231..f385a167e168 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt @@ -1,51 +1,43 @@ package org.wordpress.android.ui.newstats.topauthors 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.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Person -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 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.StatsChangeIndicator +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.StatsItemName +import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.components.StatsListRowContainer import org.wordpress.android.ui.newstats.components.StatsViewChange +import org.wordpress.android.ui.newstats.components.StatsViewsColumn 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 LOADING_ITEM_COUNT = 4 @Composable @@ -61,25 +53,23 @@ fun TopAuthorsCard( 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 TopAuthorsCardUiState.Loading -> LoadingContent() is TopAuthorsCardUiState.Loaded -> LoadedContent( uiState, onShowAllClick, onRemoveCard, cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom ) - is TopAuthorsCardUiState.Error -> ErrorContent( - uiState, onRetry, onRemoveCard, - cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom + is TopAuthorsCardUiState.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 ) } } @@ -138,48 +128,21 @@ 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_authors_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_authors_title, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) Spacer(modifier = Modifier.height(8.dp)) if (state.authors.isEmpty()) { - EmptyContent() + StatsCardEmptyContent() } else { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.stats_authors_author_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_authors_author_header) Spacer(modifier = Modifier.height(8.dp)) // Author list (capped at 10 items) @@ -200,178 +163,56 @@ 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 AuthorRow( author: TopAuthorUiItem, 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 + StatsListRowContainer(percentage = percentage) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - // Avatar (circular) - if (author.avatarUrl != null) { - AsyncImage( - model = author.avatarUrl, - contentDescription = author.name, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - ) - } else { - Box( - modifier = Modifier - .size(40.dp) - .background( - MaterialTheme.colorScheme.surfaceVariant, - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) - } - } + AuthorAvatar(avatarUrl = author.avatarUrl, name = author.name) Spacer(modifier = Modifier.width(12.dp)) - - // Author name - Text( - text = author.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + StatsItemName(name = author.name, modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(12.dp)) - - // Views count and change - Column(horizontalAlignment = Alignment.End) { - Text( - text = formatStatValue(author.views), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - StatsChangeIndicator(change = author.change) - } + StatsViewsColumn(views = author.views, change = author.change) } } } @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 - ) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - } -} - -@Composable -private fun ErrorContent( - state: TopAuthorsCardUiState.Error, - onRetry: () -> Unit, - onRemoveCard: () -> Unit, - cardPosition: CardPosition?, - onMoveUp: (() -> Unit)?, - onMoveToTop: (() -> Unit)?, - onMoveDown: (() -> Unit)?, - onMoveToBottom: (() -> Unit)? +fun AuthorAvatar( + avatarUrl: String?, + name: String, + modifier: Modifier = Modifier, + size: Int = 40 ) { - Column(modifier = Modifier.padding(CardPadding)) { - // Header with menu - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.stats_authors_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 + if (avatarUrl != null) { + AsyncImage( + model = avatarUrl, + contentDescription = name, + modifier = modifier + .size(size.dp) + .clip(CircleShape) + ) + } else { + Box( + modifier = modifier + .size(size.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), + contentAlignment = Alignment.Center ) { - Text( - text = state.message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size((size * 0.6).dp) ) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onRetry) { - Text(text = stringResource(R.string.retry)) - } } - Spacer(modifier = Modifier.height(24.dp)) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt index 3cdd3a4b29f1..a407a5299a7a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt @@ -4,52 +4,40 @@ import android.content.Context import android.content.Intent import android.os.Bundle 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.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.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Person 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 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.StatsChangeIndicator +import org.wordpress.android.ui.newstats.components.StatsItemName +import org.wordpress.android.ui.newstats.components.StatsListHeader +import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.components.StatsPositionNumber import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.components.StatsViewChange -import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.ui.newstats.components.StatsViewsColumn import org.wordpress.android.util.extensions.getParcelableArrayListCompat private const val EXTRA_AUTHORS = "extra_authors" @@ -144,7 +132,6 @@ private fun TopAuthorsDetailScreen( ) { item { Spacer(modifier = Modifier.height(8.dp)) - // Summary card StatsSummaryCard( totalViews = totalViews, dateRange = dateRange, @@ -155,22 +142,7 @@ private fun TopAuthorsDetailScreen( } item { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.stats_authors_author_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_authors_author_header) Spacer(modifier = Modifier.height(8.dp)) } @@ -201,87 +173,19 @@ private fun DetailAuthorRow( author: TopAuthorUiItem, 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) - ) - + StatsListRowContainer(percentage = percentage) { 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) - ) - - // Avatar (circular) - if (author.avatarUrl != null) { - AsyncImage( - model = author.avatarUrl, - contentDescription = author.name, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - ) - } else { - Box( - modifier = Modifier - .size(40.dp) - .background( - MaterialTheme.colorScheme.surfaceVariant, - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) - } - } + StatsPositionNumber(position = position) + AuthorAvatar(avatarUrl = author.avatarUrl, name = author.name) Spacer(modifier = Modifier.width(12.dp)) - - // Author name - Text( - text = author.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + StatsItemName(name = author.name, modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(12.dp)) - - // Views count and change - Column(horizontalAlignment = Alignment.End) { - Text( - text = formatStatValue(author.views), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - StatsChangeIndicator(change = author.change) - } + StatsViewsColumn(views = author.views, change = author.change) } } } From 28d90b94292b799c6e3f63666a93275ba2c4b27c Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 16:53:07 +0100 Subject: [PATCH 07/18] More code extraction --- .../ui/newstats/components/StatsCardCommon.kt | 71 +++++++++++++++++++ .../ui/newstats/countries/CountriesCard.kt | 33 ++++----- .../countries/CountriesDetailActivity.kt | 31 +++----- .../ui/newstats/topauthors/TopAuthorsCard.kt | 34 ++++----- .../topauthors/TopAuthorsDetailActivity.kt | 32 +++------ 5 files changed, 115 insertions(+), 86 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt index f2081067c2d8..fa5e382f6999 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt @@ -299,3 +299,74 @@ fun StatsPositionNumber(position: Int) { 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/countries/CountriesCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt index aca162ff4645..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 @@ -18,6 +18,7 @@ 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 @@ -28,10 +29,8 @@ 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.StatsItemName import org.wordpress.android.ui.newstats.components.StatsListHeader -import org.wordpress.android.ui.newstats.components.StatsListRowContainer -import org.wordpress.android.ui.newstats.components.StatsViewsColumn +import org.wordpress.android.ui.newstats.components.StatsListItem import org.wordpress.android.ui.newstats.util.ShimmerBox private val CardPadding = 16.dp @@ -208,38 +207,32 @@ private fun CountryRow( country: CountryItem, percentage: Float ) { - StatsListRowContainer(percentage = percentage) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CountryFlag(flagIconUrl = country.flagIconUrl, countryName = country.countryName) - Spacer(modifier = Modifier.width(12.dp)) - StatsItemName(name = country.countryName, modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.width(12.dp)) - StatsViewsColumn(views = country.views, change = country.change.toStatsViewChange()) - } - } + StatsListItem( + percentage = percentage, + name = country.countryName, + views = country.views, + change = country.change.toStatsViewChange(), + icon = { CountryFlag(flagIconUrl = country.flagIconUrl, countryName = country.countryName) } + ) } @Composable fun CountryFlag( flagIconUrl: String?, countryName: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + size: Dp = 24.dp ) { if (flagIconUrl != null) { AsyncImage( model = flagIconUrl, contentDescription = countryName, - modifier = modifier.size(24.dp) + modifier = modifier.size(size) ) } else { Box( modifier = modifier - .size(24.dp) + .size(size) .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp)) ) } 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 3a6c2cba90bc..746fcb530a1d 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 @@ -5,14 +5,12 @@ import android.content.Intent import android.os.Bundle import android.os.Parcelable import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons @@ -24,7 +22,6 @@ 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.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -34,12 +31,9 @@ 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.StatsItemName +import org.wordpress.android.ui.newstats.components.StatsDetailListItem import org.wordpress.android.ui.newstats.components.StatsListHeader -import org.wordpress.android.ui.newstats.components.StatsListRowContainer -import org.wordpress.android.ui.newstats.components.StatsPositionNumber import org.wordpress.android.ui.newstats.components.StatsSummaryCard -import org.wordpress.android.ui.newstats.components.StatsViewsColumn import org.wordpress.android.util.extensions.getParcelableArrayListCompat private const val EXTRA_COUNTRIES = "extra_countries" @@ -225,21 +219,14 @@ private fun DetailCountryRow( country: CountriesDetailItem, percentage: Float ) { - StatsListRowContainer(percentage = percentage) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - StatsPositionNumber(position = position) - CountryFlag(flagIconUrl = country.flagIconUrl, countryName = country.countryName) - Spacer(modifier = Modifier.width(12.dp)) - StatsItemName(name = country.countryName, modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.width(12.dp)) - StatsViewsColumn(views = country.views, change = country.change.toStatsViewChange()) - } - } + 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/topauthors/TopAuthorsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt index f385a167e168..71045b7d8282 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt @@ -20,6 +20,7 @@ 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 @@ -30,11 +31,9 @@ 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.StatsItemName import org.wordpress.android.ui.newstats.components.StatsListHeader -import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.components.StatsListItem import org.wordpress.android.ui.newstats.components.StatsViewChange -import org.wordpress.android.ui.newstats.components.StatsViewsColumn import org.wordpress.android.ui.newstats.util.ShimmerBox private val CardPadding = 16.dp @@ -168,20 +167,13 @@ private fun AuthorRow( author: TopAuthorUiItem, percentage: Float ) { - StatsListRowContainer(percentage = percentage) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AuthorAvatar(avatarUrl = author.avatarUrl, name = author.name) - Spacer(modifier = Modifier.width(12.dp)) - StatsItemName(name = author.name, modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.width(12.dp)) - StatsViewsColumn(views = author.views, change = author.change) - } - } + StatsListItem( + percentage = percentage, + name = author.name, + views = author.views, + change = author.change, + icon = { AuthorAvatar(avatarUrl = author.avatarUrl, name = author.name) } + ) } @Composable @@ -189,20 +181,20 @@ fun AuthorAvatar( avatarUrl: String?, name: String, modifier: Modifier = Modifier, - size: Int = 40 + size: Dp = 40.dp ) { if (avatarUrl != null) { AsyncImage( model = avatarUrl, contentDescription = name, modifier = modifier - .size(size.dp) + .size(size) .clip(CircleShape) ) } else { Box( modifier = modifier - .size(size.dp) + .size(size) .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape), contentAlignment = Alignment.Center ) { @@ -210,7 +202,7 @@ fun AuthorAvatar( imageVector = Icons.Default.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size((size * 0.6).dp) + modifier = Modifier.size(size * 0.6f) ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt index a407a5299a7a..fe0931a15829 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt @@ -4,13 +4,10 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons @@ -22,7 +19,6 @@ 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.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -31,13 +27,10 @@ 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.StatsItemName +import org.wordpress.android.ui.newstats.components.StatsDetailListItem import org.wordpress.android.ui.newstats.components.StatsListHeader -import org.wordpress.android.ui.newstats.components.StatsListRowContainer -import org.wordpress.android.ui.newstats.components.StatsPositionNumber import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.components.StatsViewChange -import org.wordpress.android.ui.newstats.components.StatsViewsColumn import org.wordpress.android.util.extensions.getParcelableArrayListCompat private const val EXTRA_AUTHORS = "extra_authors" @@ -173,21 +166,14 @@ private fun DetailAuthorRow( author: TopAuthorUiItem, percentage: Float ) { - StatsListRowContainer(percentage = percentage) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - StatsPositionNumber(position = position) - AuthorAvatar(avatarUrl = author.avatarUrl, name = author.name) - Spacer(modifier = Modifier.width(12.dp)) - StatsItemName(name = author.name, modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.width(12.dp)) - StatsViewsColumn(views = author.views, change = author.change) - } - } + 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) From 5fa6f505e358b6895cecbba4149236e950614f13 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 16:57:05 +0100 Subject: [PATCH 08/18] Renaming --- WordPress/src/main/AndroidManifest.xml | 2 +- .../android/ui/newstats/NewStatsActivity.kt | 34 +++++++-------- .../AuthorsCard.kt} | 42 +++++++++---------- .../AuthorsCardUiState.kt} | 18 ++++---- .../AuthorsDetailActivity.kt} | 42 +++++++++---------- .../AuthorsViewModel.kt} | 30 ++++++------- .../AuthorsViewModelTest.kt} | 34 +++++++-------- ...sTest.kt => StatsRepositoryAuthorsTest.kt} | 2 +- 8 files changed, 102 insertions(+), 102 deletions(-) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{topauthors/TopAuthorsCard.kt => authors/AuthorsCard.kt} (86%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{topauthors/TopAuthorsCardUiState.kt => authors/AuthorsCardUiState.kt} (60%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{topauthors/TopAuthorsDetailActivity.kt => authors/AuthorsDetailActivity.kt} (81%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{topauthors/TopAuthorsViewModel.kt => authors/AuthorsViewModel.kt} (85%) rename WordPress/src/test/java/org/wordpress/android/ui/newstats/{topauthors/TopAuthorsViewModelTest.kt => authors/AuthorsViewModelTest.kt} (92%) rename WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/{StatsRepositoryTopAuthorsTest.kt => StatsRepositoryAuthorsTest.kt} (99%) diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index ddae21280eb5..ea64a978cf5b 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -123,7 +123,7 @@ android:exported="false" /> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 461421ce09d4..ff8d482bafe0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -78,9 +78,9 @@ import org.wordpress.android.ui.newstats.mostviewed.MostViewedDetailActivity import org.wordpress.android.ui.newstats.mostviewed.MostViewedViewModel import org.wordpress.android.ui.newstats.todaysstats.TodaysStatsCard import org.wordpress.android.ui.newstats.todaysstats.TodaysStatsViewModel -import org.wordpress.android.ui.newstats.topauthors.TopAuthorsCard -import org.wordpress.android.ui.newstats.topauthors.TopAuthorsDetailActivity -import org.wordpress.android.ui.newstats.topauthors.TopAuthorsViewModel +import org.wordpress.android.ui.newstats.authors.AuthorsCard +import org.wordpress.android.ui.newstats.authors.AuthorsDetailActivity +import org.wordpress.android.ui.newstats.authors.AuthorsViewModel import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsCard import org.wordpress.android.ui.newstats.viewsstats.ViewsStatsViewModel @@ -231,7 +231,7 @@ private fun TrafficTabContent( todaysStatsViewModel: TodaysStatsViewModel = viewModel(), mostViewedViewModel: MostViewedViewModel = viewModel(), countriesViewModel: CountriesViewModel = viewModel(), - topAuthorsViewModel: TopAuthorsViewModel = viewModel(), + authorsViewModel: AuthorsViewModel = viewModel(), newStatsViewModel: NewStatsViewModel = viewModel() ) { val context = LocalContext.current @@ -240,15 +240,15 @@ private fun TrafficTabContent( val postsUiState by mostViewedViewModel.postsUiState.collectAsState() val referrersUiState by mostViewedViewModel.referrersUiState.collectAsState() val countriesUiState by countriesViewModel.uiState.collectAsState() - val topAuthorsUiState by topAuthorsViewModel.uiState.collectAsState() + val authorsUiState by authorsViewModel.uiState.collectAsState() val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState() val isTodaysStatsRefreshing by todaysStatsViewModel.isRefreshing.collectAsState() val isViewsStatsRefreshing by viewsStatsViewModel.isRefreshing.collectAsState() val isMostViewedRefreshing by mostViewedViewModel.isRefreshing.collectAsState() val isCountriesRefreshing by countriesViewModel.isRefreshing.collectAsState() - val isTopAuthorsRefreshing by topAuthorsViewModel.isRefreshing.collectAsState() + val isAuthorsRefreshing by authorsViewModel.isRefreshing.collectAsState() val isRefreshing = isTodaysStatsRefreshing || isViewsStatsRefreshing || - isMostViewedRefreshing || isCountriesRefreshing || isTopAuthorsRefreshing + isMostViewedRefreshing || isCountriesRefreshing || isAuthorsRefreshing val pullToRefreshState = rememberPullToRefreshState() // Card configuration state @@ -258,11 +258,11 @@ private fun TrafficTabContent( var showAddCardSheet by remember { mutableStateOf(false) } val addCardSheetState = rememberModalBottomSheetState() - // Propagate period changes to the MostViewedViewModel, CountriesViewModel, and TopAuthorsViewModel + // Propagate period changes to the MostViewedViewModel, CountriesViewModel, and AuthorsViewModel LaunchedEffect(selectedPeriod) { mostViewedViewModel.onPeriodChanged(selectedPeriod) countriesViewModel.onPeriodChanged(selectedPeriod) - topAuthorsViewModel.onPeriodChanged(selectedPeriod) + authorsViewModel.onPeriodChanged(selectedPeriod) } if (showAddCardSheet) { @@ -289,7 +289,7 @@ private fun TrafficTabContent( viewsStatsViewModel.loadData() mostViewedViewModel.loadData() countriesViewModel.loadData() - topAuthorsViewModel.loadData() + authorsViewModel.loadData() } else if (!isNetworkAvailable && !showNoConnectionScreen) { // Network became unavailable while viewing cards - show no-connection screen showNoConnectionScreen = true @@ -307,7 +307,7 @@ private fun TrafficTabContent( viewsStatsViewModel.loadData() mostViewedViewModel.loadData() countriesViewModel.loadData() - topAuthorsViewModel.loadData() + authorsViewModel.loadData() } } ) @@ -324,7 +324,7 @@ private fun TrafficTabContent( viewsStatsViewModel.refresh() mostViewedViewModel.refresh() countriesViewModel.refresh() - topAuthorsViewModel.refresh() + authorsViewModel.refresh() }, indicator = { PullToRefreshDefaults.Indicator( @@ -456,11 +456,11 @@ private fun TrafficTabContent( onMoveDown = { newStatsViewModel.moveCardDown(cardType) }, onMoveToBottom = { newStatsViewModel.moveCardToBottom(cardType) } ) - StatsCardType.AUTHORS -> TopAuthorsCard( - uiState = topAuthorsUiState, + StatsCardType.AUTHORS -> AuthorsCard( + uiState = authorsUiState, onShowAllClick = { - val detailData = topAuthorsViewModel.getDetailData() - TopAuthorsDetailActivity.start( + val detailData = authorsViewModel.getDetailData() + AuthorsDetailActivity.start( context = context, authors = detailData.authors, totalViews = detailData.totalViews, @@ -469,7 +469,7 @@ private fun TrafficTabContent( dateRange = detailData.dateRange ) }, - onRetry = topAuthorsViewModel::onRetry, + onRetry = authorsViewModel::onRetry, onRemoveCard = { newStatsViewModel.removeCard(cardType) }, cardPosition = cardPosition, onMoveUp = { newStatsViewModel.moveCardUp(cardType) }, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCard.kt similarity index 86% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCard.kt index 71045b7d8282..211d534b3cd4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCard.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.topauthors +package org.wordpress.android.ui.newstats.authors import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -40,8 +40,8 @@ private val CardPadding = 16.dp private const val LOADING_ITEM_COUNT = 4 @Composable -fun TopAuthorsCard( - uiState: TopAuthorsCardUiState, +fun AuthorsCard( + uiState: AuthorsCardUiState, onShowAllClick: () -> Unit, onRetry: () -> Unit, onRemoveCard: () -> Unit, @@ -54,12 +54,12 @@ fun TopAuthorsCard( ) { StatsCardContainer(modifier = modifier) { when (uiState) { - is TopAuthorsCardUiState.Loading -> LoadingContent() - is TopAuthorsCardUiState.Loaded -> LoadedContent( + is AuthorsCardUiState.Loading -> LoadingContent() + is AuthorsCardUiState.Loaded -> LoadedContent( uiState, onShowAllClick, onRemoveCard, cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom ) - is TopAuthorsCardUiState.Error -> StatsCardErrorContent( + is AuthorsCardUiState.Error -> StatsCardErrorContent( titleResId = R.string.stats_authors_title, errorMessage = uiState.message, onRetry = onRetry, @@ -117,7 +117,7 @@ private fun LoadingContent() { @Composable private fun LoadedContent( - state: TopAuthorsCardUiState.Loaded, + state: AuthorsCardUiState.Loaded, onShowAllClick: () -> Unit, onRemoveCard: () -> Unit, cardPosition: CardPosition?, @@ -164,7 +164,7 @@ private fun LoadedContent( @Composable private fun AuthorRow( - author: TopAuthorUiItem, + author: AuthorUiItem, percentage: Float ) { StatsListItem( @@ -211,10 +211,10 @@ fun AuthorAvatar( // Previews @Preview(showBackground = true) @Composable -private fun TopAuthorsCardLoadingPreview() { +private fun AuthorsCardLoadingPreview() { AppThemeM3 { - TopAuthorsCard( - uiState = TopAuthorsCardUiState.Loading, + AuthorsCard( + uiState = AuthorsCardUiState.Loading, onShowAllClick = {}, onRetry = {}, onRemoveCard = {} @@ -224,15 +224,15 @@ private fun TopAuthorsCardLoadingPreview() { @Preview(showBackground = true) @Composable -private fun TopAuthorsCardLoadedPreview() { +private fun AuthorsCardLoadedPreview() { AppThemeM3 { - TopAuthorsCard( - uiState = TopAuthorsCardUiState.Loaded( + AuthorsCard( + uiState = AuthorsCardUiState.Loaded( authors = listOf( - TopAuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)), - TopAuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)), - TopAuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)), - TopAuthorUiItem("Alice Brown", null, 485, StatsViewChange.NoChange) + 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 @@ -246,10 +246,10 @@ private fun TopAuthorsCardLoadedPreview() { @Preview(showBackground = true) @Composable -private fun TopAuthorsCardErrorPreview() { +private fun AuthorsCardErrorPreview() { AppThemeM3 { - TopAuthorsCard( - uiState = TopAuthorsCardUiState.Error("Failed to load author data"), + AuthorsCard( + uiState = AuthorsCardUiState.Error("Failed to load author data"), onShowAllClick = {}, onRetry = {}, onRemoveCard = {} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCardUiState.kt similarity index 60% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCardUiState.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCardUiState.kt index 063df9dfb0d8..846089ac4d26 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsCardUiState.kt @@ -1,26 +1,26 @@ -package org.wordpress.android.ui.newstats.topauthors +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 Top Authors stats card. + * UI State for the Authors stats card. */ -sealed class TopAuthorsCardUiState { - data object Loading : TopAuthorsCardUiState() +sealed class AuthorsCardUiState { + data object Loading : AuthorsCardUiState() data class Loaded( - val authors: List, + val authors: List, val maxViewsForBar: Long, val hasMoreItems: Boolean - ) : TopAuthorsCardUiState() + ) : AuthorsCardUiState() - data class Error(val message: String) : TopAuthorsCardUiState() + data class Error(val message: String) : AuthorsCardUiState() } /** - * A single author item in the top authors list. + * A single author item in the authors list. * * @param name The author's display name * @param avatarUrl URL to the author's avatar image @@ -28,7 +28,7 @@ sealed class TopAuthorsCardUiState { * @param change The change compared to the previous period */ @Parcelize -data class TopAuthorUiItem( +data class AuthorUiItem( val name: String, val avatarUrl: String?, val views: Long, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt similarity index 81% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt index fe0931a15829..c17a6287d278 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsDetailActivity.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.topauthors +package org.wordpress.android.ui.newstats.authors import android.content.Context import android.content.Intent @@ -40,12 +40,12 @@ private const val EXTRA_TOTAL_VIEWS_CHANGE_PERCENT = "extra_total_views_change_p private const val EXTRA_DATE_RANGE = "extra_date_range" @AndroidEntryPoint -class TopAuthorsDetailActivity : BaseAppCompatActivity() { +class AuthorsDetailActivity : BaseAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val authors = intent.extras - ?.getParcelableArrayListCompat(EXTRA_AUTHORS) + ?.getParcelableArrayListCompat(EXTRA_AUTHORS) ?: arrayListOf() val totalViews = intent.getLongExtra(EXTRA_TOTAL_VIEWS, 0L) val totalViewsChange = intent.getLongExtra(EXTRA_TOTAL_VIEWS_CHANGE, 0L) @@ -56,7 +56,7 @@ class TopAuthorsDetailActivity : BaseAppCompatActivity() { setContent { AppThemeM3 { - TopAuthorsDetailScreen( + AuthorsDetailScreen( authors = authors, maxViewsForBar = maxViewsForBar, totalViews = totalViews, @@ -73,13 +73,13 @@ class TopAuthorsDetailActivity : BaseAppCompatActivity() { @Suppress("LongParameterList") fun start( context: Context, - authors: List, + authors: List, totalViews: Long, totalViewsChange: Long, totalViewsChangePercent: Double, dateRange: String ) { - val intent = Intent(context, TopAuthorsDetailActivity::class.java).apply { + val intent = Intent(context, AuthorsDetailActivity::class.java).apply { putExtra(EXTRA_AUTHORS, ArrayList(authors)) putExtra(EXTRA_TOTAL_VIEWS, totalViews) putExtra(EXTRA_TOTAL_VIEWS_CHANGE, totalViewsChange) @@ -93,8 +93,8 @@ class TopAuthorsDetailActivity : BaseAppCompatActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TopAuthorsDetailScreen( - authors: List, +private fun AuthorsDetailScreen( + authors: List, maxViewsForBar: Long, totalViews: Long, totalViewsChange: Long, @@ -163,7 +163,7 @@ private fun TopAuthorsDetailScreen( @Composable private fun DetailAuthorRow( position: Int, - author: TopAuthorUiItem, + author: AuthorUiItem, percentage: Float ) { StatsDetailListItem( @@ -178,20 +178,20 @@ private fun DetailAuthorRow( @Preview(showBackground = true) @Composable -private fun TopAuthorsDetailScreenPreview() { +private fun AuthorsDetailScreenPreview() { AppThemeM3 { - TopAuthorsDetailScreen( + AuthorsDetailScreen( authors = listOf( - TopAuthorUiItem("John Doe", null, 3464, StatsViewChange.Positive(124, 3.7)), - TopAuthorUiItem("Jane Smith", null, 556, StatsViewChange.Positive(45, 8.8)), - TopAuthorUiItem("Bob Johnson", null, 522, StatsViewChange.Negative(12, 2.2)), - TopAuthorUiItem("Alice Brown", null, 485, StatsViewChange.Positive(33, 7.3)), - TopAuthorUiItem("Charlie Wilson", null, 412, StatsViewChange.NoChange), - TopAuthorUiItem("Diana Miller", null, 387, StatsViewChange.Negative(8, 2.0)), - TopAuthorUiItem("Edward Davis", null, 298, StatsViewChange.Positive(21, 7.6)), - TopAuthorUiItem("Fiona Garcia", null, 245, StatsViewChange.Positive(15, 6.5)), - TopAuthorUiItem("George Martinez", null, 201, StatsViewChange.Negative(5, 2.4)), - TopAuthorUiItem("Hannah Anderson", null, 156, StatsViewChange.Positive(12, 8.3)) + 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, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt similarity index 85% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModel.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt index 42b330e97a6a..7d77a7678c0b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModel.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.topauthors +package org.wordpress.android.ui.newstats.authors import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -23,21 +23,21 @@ import kotlin.math.abs private const val CARD_MAX_ITEMS = 10 @HiltViewModel -class TopAuthorsViewModel @Inject constructor( +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(TopAuthorsCardUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + 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 allAuthors: List = emptyList() private var cachedTotalViews: Long = 0L private var cachedTotalViewsChange: Long = 0L private var cachedTotalViewsChangePercent: Double = 0.0 @@ -48,11 +48,11 @@ class TopAuthorsViewModel @Inject constructor( fun loadData() { viewModelScope.launch { - _uiState.value = TopAuthorsCardUiState.Loading + _uiState.value = AuthorsCardUiState.Loading val site = selectedSiteRepository.getSelectedSite() if (site == null) { - _uiState.value = TopAuthorsCardUiState.Error("No site selected") + _uiState.value = AuthorsCardUiState.Error("No site selected") return@launch } @@ -86,8 +86,8 @@ class TopAuthorsViewModel @Inject constructor( } } - fun getDetailData(): TopAuthorsDetailData { - return TopAuthorsDetailData( + fun getDetailData(): AuthorsDetailData { + return AuthorsDetailData( authors = allAuthors, totalViews = cachedTotalViews, totalViewsChange = cachedTotalViewsChange, @@ -113,14 +113,14 @@ class TopAuthorsViewModel @Inject constructor( if (result.authors.isEmpty()) { allAuthors = emptyList() - _uiState.value = TopAuthorsCardUiState.Loaded( + _uiState.value = AuthorsCardUiState.Loaded( authors = emptyList(), maxViewsForBar = 0, hasMoreItems = false ) } else { val authors = result.authors.map { author -> - TopAuthorUiItem( + AuthorUiItem( name = author.name, avatarUrl = author.avatarUrl, views = author.views, @@ -135,7 +135,7 @@ class TopAuthorsViewModel @Inject constructor( val cardAuthors = authors.take(CARD_MAX_ITEMS) val maxViewsForBar = cardAuthors.firstOrNull()?.views ?: 1L - _uiState.value = TopAuthorsCardUiState.Loaded( + _uiState.value = AuthorsCardUiState.Loaded( authors = cardAuthors, maxViewsForBar = maxViewsForBar, hasMoreItems = authors.size > CARD_MAX_ITEMS @@ -143,7 +143,7 @@ class TopAuthorsViewModel @Inject constructor( } } is TopAuthorsResult.Error -> { - _uiState.value = TopAuthorsCardUiState.Error(result.message) + _uiState.value = AuthorsCardUiState.Error(result.message) } } } @@ -157,8 +157,8 @@ class TopAuthorsViewModel @Inject constructor( } } -data class TopAuthorsDetailData( - val authors: List, +data class AuthorsDetailData( + val authors: List, val totalViews: Long, val totalViewsChange: Long, val totalViewsChangePercent: Double, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt similarity index 92% rename from WordPress/src/test/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModelTest.kt rename to WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt index 2e8cd951bc8b..427c73a18cda 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/topauthors/TopAuthorsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/authors/AuthorsViewModelTest.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.topauthors +package org.wordpress.android.ui.newstats.authors import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat @@ -23,7 +23,7 @@ import org.wordpress.android.ui.newstats.repository.TopAuthorsResult import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi -class TopAuthorsViewModelTest : BaseUnitTest() { +class AuthorsViewModelTest : BaseUnitTest() { @Mock private lateinit var selectedSiteRepository: SelectedSiteRepository @@ -36,7 +36,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { @Mock private lateinit var resourceProvider: ResourceProvider - private lateinit var viewModel: TopAuthorsViewModel + private lateinit var viewModel: AuthorsViewModel private val testSite = SiteModel().apply { id = 1 @@ -51,7 +51,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { } private fun initViewModel() { - viewModel = TopAuthorsViewModel( + viewModel = AuthorsViewModel( selectedSiteRepository, accountStore, statsRepository, @@ -68,8 +68,8 @@ class TopAuthorsViewModelTest : BaseUnitTest() { advanceUntilIdle() val state = viewModel.uiState.value - assertThat(state).isInstanceOf(TopAuthorsCardUiState.Error::class.java) - assertThat((state as TopAuthorsCardUiState.Error).message).isEqualTo("No site selected") + assertThat(state).isInstanceOf(AuthorsCardUiState.Error::class.java) + assertThat((state as AuthorsCardUiState.Error).message).isEqualTo("No site selected") } @Test @@ -81,8 +81,8 @@ class TopAuthorsViewModelTest : BaseUnitTest() { advanceUntilIdle() val state = viewModel.uiState.value - assertThat(state).isInstanceOf(TopAuthorsCardUiState.Error::class.java) - assertThat((state as TopAuthorsCardUiState.Error).message).isEqualTo(ERROR_MESSAGE) + assertThat(state).isInstanceOf(AuthorsCardUiState.Error::class.java) + assertThat((state as AuthorsCardUiState.Error).message).isEqualTo(ERROR_MESSAGE) } // endregion @@ -96,7 +96,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { advanceUntilIdle() val state = viewModel.uiState.value - assertThat(state).isInstanceOf(TopAuthorsCardUiState.Loaded::class.java) + assertThat(state).isInstanceOf(AuthorsCardUiState.Loaded::class.java) } @Test @@ -107,7 +107,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + 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) @@ -124,7 +124,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + val state = viewModel.uiState.value as AuthorsCardUiState.Loaded assertThat(state.maxViewsForBar).isEqualTo(TEST_AUTHOR_VIEWS_1) } @@ -151,7 +151,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + val state = viewModel.uiState.value as AuthorsCardUiState.Loaded assertThat(state.authors).hasSize(10) assertThat(state.hasMoreItems).isTrue() } @@ -164,7 +164,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + val state = viewModel.uiState.value as AuthorsCardUiState.Loaded assertThat(state.hasMoreItems).isFalse() } @@ -182,7 +182,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + val state = viewModel.uiState.value as AuthorsCardUiState.Loaded assertThat(state.authors).isEmpty() assertThat(state.maxViewsForBar).isEqualTo(0L) assertThat(state.hasMoreItems).isFalse() @@ -363,7 +363,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + 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) @@ -393,7 +393,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + 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) @@ -423,7 +423,7 @@ class TopAuthorsViewModelTest : BaseUnitTest() { initViewModel() advanceUntilIdle() - val state = viewModel.uiState.value as TopAuthorsCardUiState.Loaded + val state = viewModel.uiState.value as AuthorsCardUiState.Loaded assertThat(state.authors[0].change).isEqualTo(StatsViewChange.NoChange) } // endregion diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTopAuthorsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryAuthorsTest.kt similarity index 99% rename from WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTopAuthorsTest.kt rename to WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryAuthorsTest.kt index 3b907dd67094..b51899c174d6 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTopAuthorsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryAuthorsTest.kt @@ -19,7 +19,7 @@ import org.wordpress.android.ui.newstats.datasource.TopAuthorsData import org.wordpress.android.ui.newstats.datasource.TopAuthorsDataResult @ExperimentalCoroutinesApi -class StatsRepositoryTopAuthorsTest : BaseUnitTest() { +class StatsRepositoryAuthorsTest : BaseUnitTest() { @Mock private lateinit var statsDataSource: StatsDataSource From f5bf375ad3cd54449865367079429319e3bdb4d3 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Feb 2026 16:59:29 +0100 Subject: [PATCH 09/18] Reverting rollback change by mistake --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7782ab41d5a..3a21e197628f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,7 @@ junit = '4.13.2' kotlin-compile-testing = '1.6.0' kotlin-main = '2.2.10' kotlinx-coroutines = '1.10.2' -kotlinx-kover = '0.9.5' +kotlinx-kover = '0.9.6' ksp = '2.2.10-2.0.2' mockito-android = '5.21.0' mockito-kotlin = '6.2.3' From 593648d55ef022964103b64d1cd853ded5fe708e Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Feb 2026 15:47:56 +0100 Subject: [PATCH 10/18] Updating version --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 727743ac822f..08e14b936d5c 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 = '1151-b0c0d6afcbbcd406589074a073b64513468247b1' +wordpress-rs = '1151-58d91c951e1967880df2365f43bc6d008d5a79b7' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2' From 210aa2338a6c8443bed694a2d1142704fd77c53d Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Feb 2026 15:48:35 +0100 Subject: [PATCH 11/18] Checking token --- .../ui/newstats/authors/AuthorsViewModel.kt | 46 ++++++++++--------- .../newstats/countries/CountriesViewModel.kt | 46 ++++++++++--------- 2 files changed, 50 insertions(+), 42 deletions(-) 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 index 7d77a7678c0b..368551ffed0e 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -47,30 +48,39 @@ class AuthorsViewModel @Inject constructor( } fun loadData() { - viewModelScope.launch { - _uiState.value = AuthorsCardUiState.Loading + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + _uiState.value = AuthorsCardUiState.Error( + resourceProvider.getString(R.string.stats_todays_stats_no_site_selected) + ) + return + } - val site = selectedSiteRepository.getSelectedSite() - if (site == null) { - _uiState.value = AuthorsCardUiState.Error("No site selected") - return@launch - } + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + _uiState.value = AuthorsCardUiState.Error( + resourceProvider.getString(R.string.stats_todays_stats_failed_to_load) + ) + return + } - initializeRepository() + 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 - - val site = selectedSiteRepository.getSelectedSite() - if (site != null) { - initializeRepository() - fetchTopAuthors(site) - } - + fetchTopAuthors(site) _isRefreshing.value = false } } @@ -96,12 +106,6 @@ class AuthorsViewModel @Inject constructor( ) } - private fun initializeRepository() { - accountStore.accessToken?.let { token -> - statsRepository.init(token) - } - } - private suspend fun fetchTopAuthors(site: SiteModel) { val siteId = site.siteId 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..ca91f89124d9 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 From b42f1ef0add0ed9f932dd41812ef67f4d80aea48 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Feb 2026 15:54:52 +0100 Subject: [PATCH 12/18] Division by zero fix --- .../android/ui/newstats/authors/AuthorsDetailActivity.kt | 2 +- .../wordpress/android/ui/newstats/authors/AuthorsViewModel.kt | 2 +- .../android/ui/newstats/countries/CountriesDetailActivity.kt | 2 +- .../android/ui/newstats/countries/CountriesViewModel.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 index c17a6287d278..54e477f9b237 100644 --- 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 @@ -52,7 +52,7 @@ class AuthorsDetailActivity : 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 = authors.firstOrNull()?.views ?: 1L + val maxViewsForBar = authors.firstOrNull()?.views ?: 0L setContent { AppThemeM3 { 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 index 368551ffed0e..a61ecbdb7eff 100644 --- 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 @@ -137,7 +137,7 @@ class AuthorsViewModel @Inject constructor( // 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 ?: 1L + val maxViewsForBar = cardAuthors.firstOrNull()?.views ?: 0L _uiState.value = AuthorsCardUiState.Loaded( authors = cardAuthors, 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 746fcb530a1d..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 @@ -62,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 { 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 ca91f89124d9..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 @@ -157,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, From 6b37847bbe5d8247e992f4859a7e8e31acb7c366 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Feb 2026 16:56:27 +0100 Subject: [PATCH 13/18] Fixing the max authors issue --- .../android/ui/newstats/repository/StatsRepository.kt | 4 ++-- .../ui/newstats/repository/StatsRepositoryAuthorsTest.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 c998b011aaf1..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 @@ -752,8 +752,8 @@ class StatsRepository @Inject constructor( // Fetch both periods in parallel val (currentResult, previousResult) = coroutineScope { - val currentDeferred = async { statsDataSource.fetchTopAuthors(siteId, currentDateRange) } - val previousDeferred = async { statsDataSource.fetchTopAuthors(siteId, previousDateRange) } + val currentDeferred = async { statsDataSource.fetchTopAuthors(siteId, currentDateRange, max = 0) } + val previousDeferred = async { statsDataSource.fetchTopAuthors(siteId, previousDateRange, max = 0) } currentDeferred.await() to previousDeferred.await() } 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 index b51899c174d6..18e647bb1dcf 100644 --- 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 @@ -183,11 +183,11 @@ class StatsRepositoryAuthorsTest : BaseUnitTest() { repository.fetchTopAuthors(TEST_SITE_ID, StatsPeriod.Last7Days) - // Verify data source is called twice (current and previous period) + // 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 = any() + max = eq(0) ) } From 0b205203a319f69f29529d140ecea197f654c153 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Feb 2026 17:00:14 +0100 Subject: [PATCH 14/18] Fix tests --- .../android/ui/newstats/authors/AuthorsViewModelTest.kt | 6 +++++- .../android/ui/newstats/countries/CountriesViewModelTest.kt | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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 index 427c73a18cda..68687bbfd6cb 100644 --- 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 @@ -63,13 +63,15 @@ class AuthorsViewModelTest : 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(AuthorsCardUiState.Error::class.java) - assertThat((state as AuthorsCardUiState.Error).message).isEqualTo("No site selected") + assertThat((state as AuthorsCardUiState.Error).message).isEqualTo(NO_SITE_SELECTED_ERROR) } @Test @@ -454,6 +456,8 @@ class AuthorsViewModelTest : 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 FAILED_TO_LOAD_ERROR = "Failed to load" private const val TEST_AUTHOR_NAME_1 = "John Doe" private const val TEST_AUTHOR_NAME_2 = "Jane Smith" 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..ec872b6901e9 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,8 @@ 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 FAILED_TO_LOAD_ERROR = "Failed to load" private const val TEST_COUNTRY_CODE_1 = "US" private const val TEST_COUNTRY_CODE_2 = "UK" From 106b326cb5c78920a9d0a07f8ee5bd3d0fc34bdd Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Feb 2026 17:02:06 +0100 Subject: [PATCH 15/18] detekt --- .../android/ui/newstats/authors/AuthorsViewModelTest.kt | 1 - .../android/ui/newstats/countries/CountriesViewModelTest.kt | 1 - 2 files changed, 2 deletions(-) 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 index 68687bbfd6cb..c6528e2512a0 100644 --- 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 @@ -457,7 +457,6 @@ class AuthorsViewModelTest : BaseUnitTest() { 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 FAILED_TO_LOAD_ERROR = "Failed to load" private const val TEST_AUTHOR_NAME_1 = "John Doe" private const val TEST_AUTHOR_NAME_2 = "Jane Smith" 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 ec872b6901e9..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 @@ -527,7 +527,6 @@ class CountriesViewModelTest : BaseUnitTest() { 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 FAILED_TO_LOAD_ERROR = "Failed to load" private const val TEST_COUNTRY_CODE_1 = "US" private const val TEST_COUNTRY_CODE_2 = "UK" From 7eecae839e4704ee3eb94fcea1aebd58db87d43a Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 9 Feb 2026 17:42:44 +0100 Subject: [PATCH 16/18] Using the proper endpoint --- .../datasource/StatsDataSourceImpl.kt | 114 ++++++++++-------- 1 file changed, 65 insertions(+), 49 deletions(-) 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 3d9fa036678f..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,21 +4,17 @@ 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 org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient -import org.wordpress.android.fluxc.network.utils.StatsGranularity +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 -import java.time.LocalDate -import java.time.ZoneId -import java.util.Date import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import rs.wordpress.api.kotlin.fromLocale @@ -30,8 +26,7 @@ import javax.inject.Inject */ class StatsDataSourceImpl @Inject constructor( private val wpComApiClientProvider: WpComApiClientProvider, - private val localeManagerWrapper: LocaleManagerWrapper, - private val authorsRestClient: AuthorsRestClient + private val localeManagerWrapper: LocaleManagerWrapper ) : StatsDataSource { /** * Access token for API authentication. @@ -325,56 +320,59 @@ class StatsDataSourceImpl @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") + 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 date = when (dateRange) { - is StatsDateRange.Preset -> LocalDate.parse(dateRange.date) - is StatsDateRange.Custom -> LocalDate.parse(dateRange.date) - } - val javaDate = Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant()) + val params = buildTopAuthorsParams(dateRange, max) + AppLog.d(T.STATS, "fetchTopAuthors - siteId=$siteId, dateRange=$dateRange, max=$max") - // Create a minimal SiteModel with just the siteId - val site = SiteModel().apply { this.siteId = siteId } + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsTopAuthors().getStatsTopAuthors( + wpComSiteId = siteId.toULong(), + params = params + ) + } - AppLog.d(T.STATS, "StatsDataSourceImpl: fetchTopAuthors - siteId=$siteId, date=$date, max=$max") + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchTopAuthors result type: ${result::class.simpleName}") - return try { - val result = authorsRestClient.fetchAuthors( - site = site, - granularity = StatsGranularity.DAYS, - date = javaDate, - itemsToLoad = max, - forced = true - ) + return when (result) { + is WpRequestResult.Success -> { + val authors = result.response.data.summary?.authors.orEmpty() + AppLog.d( + T.STATS, + "StatsDataSourceImpl: fetchTopAuthors success - ${authors.size} authors" + ) - if (result.isError) { - val errorMessage = result.error?.message ?: "Unknown error" - AppLog.e(T.STATS, "StatsDataSourceImpl: fetchTopAuthors error - $errorMessage") - TopAuthorsDataResult.Error(errorMessage) - } else { - val response = result.response - // Get the first (and typically only) group of authors - val authorsGroup = response?.groups?.values?.firstOrNull() - val authorsList = authorsGroup?.authors.orEmpty() - - val authorItems = authorsList.map { author -> + val authorItems = authors.map { author -> TopAuthorItem( - name = author.name.orEmpty(), - avatarUrl = author.avatarUrl, - views = author.views?.toLong() ?: 0L + name = author.name, + avatarUrl = author.avatar, + views = author.views.toLong() ) } - val totalViews = authorItems.sumOf { it.views } - AppLog.d( - T.STATS, - "StatsDataSourceImpl: fetchTopAuthors success - ${authorItems.size} authors" - ) TopAuthorsDataResult.Success( TopAuthorsData( authors = authorItems, @@ -382,9 +380,27 @@ class StatsDataSourceImpl @Inject constructor( ) ) } - } catch (e: Exception) { - AppLog.e(T.STATS, "StatsDataSourceImpl: fetchTopAuthors exception - ${e.message}") - TopAuthorsDataResult.Error(e.message ?: "Unknown error") + 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}") + } } } } From 4e7fe7dde4a7f4ba9f2b5c0a905b254904f73a9b Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 10 Feb 2026 14:49:26 +0100 Subject: [PATCH 17/18] Update RS --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08e14b936d5c..c00e9eaef4aa 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 = '1151-58d91c951e1967880df2365f43bc6d008d5a79b7' +wordpress-rs = '1151-2907327859d7464a48e3513d49973408ab1fca23' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2' From b46fe2ae8ad49eaa2bceb337b691cec1e9d14aed Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 18 Feb 2026 18:15:08 +0100 Subject: [PATCH 18/18] retargeting RS trunk --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c0f9e709d08..4ea98f353a88 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 = '1151-2907327859d7464a48e3513d49973408ab1fca23' +wordpress-rs = 'trunk-9edcee430afd18d7d440baf497a763f9b1bb83d9' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2'