From ff48e1a253204f285d4a4f122beb16cd63b62537 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 29 Jan 2026 16:09:34 +0100 Subject: [PATCH 01/19] Adding the countries card --- .../android/ui/newstats/NewStatsActivity.kt | 18 +- .../ui/newstats/countries/CountriesCard.kt | 505 ++++++++++++++++++ .../countries/CountriesCardUiState.kt | 32 ++ .../newstats/countries/CountriesViewModel.kt | 133 +++++ .../ui/newstats/datasource/StatsDataSource.kt | 41 ++ .../ui/newstats/repository/StatsRepository.kt | 77 +++ WordPress/src/main/res/values/strings.xml | 5 + gradle/libs.versions.toml | 2 +- 8 files changed, 809 insertions(+), 4 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt 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 79e90b8ac181..8082ec171712 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 @@ -49,6 +49,8 @@ import kotlinx.coroutines.launch 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.countries.CountriesCard +import org.wordpress.android.ui.newstats.countries.CountriesViewModel import org.wordpress.android.ui.newstats.mostviewed.MostViewedCard import org.wordpress.android.ui.newstats.mostviewed.MostViewedDetailActivity import org.wordpress.android.ui.newstats.mostviewed.MostViewedViewModel @@ -191,22 +193,27 @@ private fun StatsTabContent(tab: StatsTab, viewsStatsViewModel: ViewsStatsViewMo private fun TrafficTabContent( viewsStatsViewModel: ViewsStatsViewModel, todaysStatsViewModel: TodaysStatsViewModel = viewModel(), - mostViewedViewModel: MostViewedViewModel = viewModel() + mostViewedViewModel: MostViewedViewModel = viewModel(), + countriesViewModel: CountriesViewModel = viewModel() ) { val context = LocalContext.current val todaysStatsUiState by todaysStatsViewModel.uiState.collectAsState() val viewsStatsUiState by viewsStatsViewModel.uiState.collectAsState() val mostViewedUiState by mostViewedViewModel.uiState.collectAsState() + val countriesUiState by countriesViewModel.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 isRefreshing = isTodaysStatsRefreshing || isViewsStatsRefreshing || isMostViewedRefreshing + val isCountriesRefreshing by countriesViewModel.isRefreshing.collectAsState() + val isRefreshing = isTodaysStatsRefreshing || isViewsStatsRefreshing || + isMostViewedRefreshing || isCountriesRefreshing val pullToRefreshState = rememberPullToRefreshState() - // Propagate period changes to the MostViewedViewModel + // Propagate period changes to the MostViewedViewModel and CountriesViewModel LaunchedEffect(selectedPeriod) { mostViewedViewModel.onPeriodChanged(selectedPeriod) + countriesViewModel.onPeriodChanged(selectedPeriod) } PullToRefreshBox( @@ -217,6 +224,7 @@ private fun TrafficTabContent( todaysStatsViewModel.refresh() viewsStatsViewModel.refresh() mostViewedViewModel.refresh() + countriesViewModel.refresh() }, indicator = { PullToRefreshDefaults.Indicator( @@ -255,6 +263,10 @@ private fun TrafficTabContent( }, onRetry = mostViewedViewModel::onRetry ) + CountriesCard( + uiState = countriesUiState, + onRetry = countriesViewModel::onRetry + ) } } } 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 new file mode 100644 index 000000000000..8288bfd3e77d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt @@ -0,0 +1,505 @@ +package org.wordpress.android.ui.newstats.countries + +import android.annotation.SuppressLint +import android.graphics.Color +import android.util.Base64 +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import coil.compose.AsyncImage +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +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 + +@Composable +fun CountriesCard( + uiState: CountriesCardUiState, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + 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 CountriesCardUiState.Loading -> LoadingContent() + is CountriesCardUiState.Loaded -> LoadedContent(uiState) + is CountriesCardUiState.Error -> ErrorContent(uiState, onRetry) + } + } +} + +@Composable +private fun LoadingContent() { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnimation = transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_translate" + ) + + val shimmerBrush = Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnimation.value - 500f, 0f), + end = Offset(translateAnimation.value, 0f) + ) + + Column(modifier = Modifier.padding(CardPadding)) { + // Title placeholder + Box( + modifier = Modifier + .width(100.dp) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(16.dp)) + + // Map placeholder + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(MAP_ASPECT_RATIO) + .clip(RoundedCornerShape(8.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(16.dp)) + + // Legend placeholder + Box( + modifier = Modifier + .width(150.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(16.dp)) + + // List items placeholders + repeat(4) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier + .weight(1f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier + .width(50.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + } + } + } +} + +@Composable +private fun LoadedContent(state: CountriesCardUiState.Loaded) { + Column(modifier = Modifier.padding(CardPadding)) { + // Title + Text( + text = stringResource(R.string.stats_countries_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (state.countries.isEmpty()) { + EmptyContent() + } else { + // Map + CountryMap( + mapData = state.mapData, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(MAP_ASPECT_RATIO) + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Legend + MapLegend( + minViews = state.minViews, + maxViews = state.maxViews + ) + 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 + ) + } + Spacer(modifier = Modifier.height(8.dp)) + + // Country list + LazyColumn( + modifier = Modifier.height((state.countries.size * 56).coerceAtMost(280).dp) + ) { + itemsIndexed(state.countries) { index, country -> + CountryRow( + country = country, + showDivider = index < state.countries.size - 1 + ) + } + } + } + } +} + +@Composable +private fun EmptyContent() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.stats_no_data_for_period), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun CountryMap( + mapData: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toHexString() + val colorHigh = MaterialTheme.colorScheme.primary.toHexString() + val backgroundColor = MaterialTheme.colorScheme.surface.toHexString() + val emptyColor = MaterialTheme.colorScheme.surfaceVariant.toHexString() + val viewsLabel = stringResource(R.string.stats_countries_views_header) + + val htmlPage = remember(mapData, colorLow, colorHigh, backgroundColor, emptyColor, viewsLabel) { + buildMapHtml(mapData, viewsLabel, colorLow, colorHigh, emptyColor, backgroundColor) + } + + AndroidView( + modifier = modifier.clip(RoundedCornerShape(8.dp)), + factory = { ctx -> + WebView(ctx).apply { + setBackgroundColor(Color.TRANSPARENT) + settings.javaScriptEnabled = true + settings.cacheMode = WebSettings.LOAD_NO_CACHE + } + }, + update = { webView -> + val base64Html = Base64.encodeToString(htmlPage.toByteArray(), Base64.DEFAULT) + webView.loadData(base64Html, "text/html; charset=UTF-8", "base64") + } + ) +} + +private fun buildMapHtml( + mapData: String, + viewsLabel: String, + colorLow: String, + colorHigh: String, + emptyColor: String, + backgroundColor: String +): String { + return """ + + + + + + +
+ + + """.trimIndent() +} + +@Composable +private fun MapLegend( + minViews: Long, + maxViews: Long +) { + val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + val colorHigh = MaterialTheme.colorScheme.primary + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatStatValue(minViews), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .weight(1f) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + Brush.horizontalGradient( + colors = listOf(colorLow, colorHigh) + ) + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formatStatValue(maxViews), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun CountryRow( + country: CountryItem, + showDivider: Boolean +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Flag icon + if (country.flagIconUrl != null) { + AsyncImage( + model = country.flagIconUrl, + contentDescription = country.countryName, + modifier = Modifier.size(24.dp) + ) + } else { + Box( + modifier = Modifier + .size(24.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + + // Country name + Text( + text = country.countryName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(12.dp)) + + // Views count + Text( + text = formatStatValue(country.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 1.dp + ) + } + } +} + +@Composable +private fun ErrorContent( + state: CountriesCardUiState.Error, + onRetry: () -> Unit +) { + Column(modifier = Modifier.padding(CardPadding)) { + Text( + text = stringResource(R.string.stats_countries_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + 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)) + } +} + +private fun androidx.compose.ui.graphics.Color.toHexString(): String { + val argb = this.toArgb() + return String.format("%06X", argb and 0xFFFFFF) +} + +// Previews +@Preview(showBackground = true) +@Composable +private fun CountriesCardLoadingPreview() { + AppThemeM3 { + CountriesCard( + uiState = CountriesCardUiState.Loading, + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CountriesCardLoadedPreview() { + AppThemeM3 { + CountriesCard( + uiState = CountriesCardUiState.Loaded( + countries = listOf( + CountryItem("US", "United States", 3464, null), + CountryItem("ES", "Spain", 556, null), + CountryItem("GB", "United Kingdom", 522, null), + CountryItem("CA", "Canada", 485, null) + ), + mapData = "['US',3464],['ES',556],['GB',522],['CA',485]", + minViews = 485, + maxViews = 3464 + ), + onRetry = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CountriesCardErrorPreview() { + AppThemeM3 { + CountriesCard( + uiState = CountriesCardUiState.Error("Failed to load country data"), + onRetry = {} + ) + } +} 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 new file mode 100644 index 000000000000..745a9a3c1d65 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCardUiState.kt @@ -0,0 +1,32 @@ +package org.wordpress.android.ui.newstats.countries + +/** + * UI State for the Countries stats card. + */ +sealed class CountriesCardUiState { + data object Loading : CountriesCardUiState() + + data class Loaded( + val countries: List, + val mapData: String, + val minViews: Long, + val maxViews: Long + ) : CountriesCardUiState() + + data class Error(val message: String) : CountriesCardUiState() +} + +/** + * A single country item in the countries list. + * + * @param countryCode ISO 3166-1 alpha-2 country code (e.g., "US", "GB") + * @param countryName Full country name + * @param views Number of views from this country + * @param flagIconUrl URL to the country flag icon + */ +data class CountryItem( + val countryCode: String, + val countryName: String, + val views: Long, + val flagIconUrl: String? +) 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 new file mode 100644 index 000000000000..87d8926a4943 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt @@ -0,0 +1,133 @@ +package org.wordpress.android.ui.newstats.countries + +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.repository.CountryViewsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import javax.inject.Inject + +@HiltViewModel +class CountriesViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(CountriesCardUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private var currentPeriod: StatsPeriod = StatsPeriod.Last7Days + + init { + loadData() + } + + fun loadData() { + viewModelScope.launch { + _uiState.value = CountriesCardUiState.Loading + + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + _uiState.value = CountriesCardUiState.Error("No site selected") + return@launch + } + + initializeRepository() + fetchCountryViews(site) + } + } + + fun refresh() { + viewModelScope.launch { + _isRefreshing.value = true + + val site = selectedSiteRepository.getSelectedSite() + if (site != null) { + initializeRepository() + fetchCountryViews(site) + } + + _isRefreshing.value = false + } + } + + fun onRetry() { + loadData() + } + + fun onPeriodChanged(period: StatsPeriod) { + if (currentPeriod != period) { + currentPeriod = period + loadData() + } + } + + private fun initializeRepository() { + accountStore.accessToken?.let { token -> + statsRepository.init(token) + } + } + + private suspend fun fetchCountryViews(site: SiteModel) { + val siteId = site.siteId + + when (val result = statsRepository.fetchCountryViews(siteId, currentPeriod)) { + is CountryViewsResult.Success -> { + if (result.countries.isEmpty()) { + _uiState.value = CountriesCardUiState.Loaded( + countries = emptyList(), + mapData = "", + minViews = 0, + maxViews = 0 + ) + } else { + val countries = result.countries.map { country -> + CountryItem( + countryCode = country.countryCode, + countryName = country.countryName, + views = country.views, + flagIconUrl = country.flagIconUrl + ) + } + + // Build map data for Google GeoChart + val mapData = buildMapData(countries) + val minViews = countries.minOfOrNull { it.views } ?: 0L + val maxViews = countries.maxOfOrNull { it.views } ?: 0L + + _uiState.value = CountriesCardUiState.Loaded( + countries = countries, + mapData = mapData, + minViews = if (minViews == maxViews) 0L else minViews, + maxViews = maxViews + ) + } + } + is CountryViewsResult.Error -> { + _uiState.value = CountriesCardUiState.Error(result.message) + } + } + } + + /** + * Builds the map data string for Google GeoChart. + * Format: ['countryCode',views],['countryCode',views],... + */ + private fun buildMapData(countries: List): String { + return countries.joinToString(",") { country -> + "['${country.countryCode}',${country.views}]" + } + } +} 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 699fa9be5845..a3462b373f6a 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 @@ -53,6 +53,20 @@ interface StatsDataSource { dateRange: StatsDateRange, max: Int = 10 ): ReferrersDataResult + + /** + * Fetches country views 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 countries to return + * @return Result containing the country views data or an error + */ + suspend fun fetchCountryViews( + siteId: Long, + dateRange: StatsDateRange, + max: Int = 10 + ): CountryViewsDataResult } /** @@ -172,3 +186,30 @@ data class ReferrerDataItem( val name: String, val views: Long ) + +/** + * Result wrapper for country views fetch operation. + */ +sealed class CountryViewsDataResult { + data class Success(val data: CountryViewsData) : CountryViewsDataResult() + data class Error(val message: String) : CountryViewsDataResult() +} + +/** + * Country views data from the API. + */ +data class CountryViewsData( + val countries: List, + val totalViews: Long, + val otherViews: Long +) + +/** + * A single country view item from the API. + */ +data class CountryViewItem( + val countryCode: String, + val countryName: String, + val views: Long, + val flagIconUrl: String? +) 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 7b3452731e22..59ea07a01d06 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 @@ -3,6 +3,7 @@ package org.wordpress.android.ui.newstats.repository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import org.wordpress.android.ui.newstats.datasource.CountryViewsDataResult import org.wordpress.android.ui.newstats.datasource.ReferrersDataResult import org.wordpress.android.ui.newstats.datasource.StatsDataSource import org.wordpress.android.ui.newstats.datasource.StatsDateRange @@ -666,6 +667,60 @@ class StatsRepository @Inject constructor( } } } + + /** + * Fetches country views stats for a specific site and period. + * + * @param siteId The WordPress.com site ID + * @param period The stats period to fetch + * @return Country views data or error + */ + suspend fun fetchCountryViews( + siteId: Long, + period: StatsPeriod + ): CountryViewsResult = withContext(ioDispatcher) { + val dateRange = calculateCountryViewsDateRange(period) + + val result = statsDataSource.fetchCountryViews(siteId, dateRange) + + when (result) { + is CountryViewsDataResult.Success -> { + CountryViewsResult.Success( + countries = result.data.countries.map { country -> + CountryViewItemData( + countryCode = country.countryCode, + countryName = country.countryName, + views = country.views, + flagIconUrl = country.flagIconUrl + ) + }, + totalViews = result.data.totalViews, + otherViews = result.data.otherViews + ) + } + is CountryViewsDataResult.Error -> { + appLogWrapper.e(AppLog.T.STATS, "Error fetching country views: ${result.message}") + CountryViewsResult.Error(result.message) + } + } + } + + private fun calculateCountryViewsDateRange(period: StatsPeriod): StatsDateRange { + val today = LocalDate.now() + val todayString = today.format(dateFormatter) + + return when (period) { + is StatsPeriod.Today -> StatsDateRange.Preset(num = NUM_DAYS_TODAY, date = todayString) + is StatsPeriod.Last7Days -> StatsDateRange.Preset(num = DAYS_IN_7_DAYS, date = todayString) + is StatsPeriod.Last30Days -> StatsDateRange.Preset(num = DAYS_IN_30_DAYS, date = todayString) + is StatsPeriod.Last6Months -> StatsDateRange.Preset(num = DAYS_IN_6_MONTHS, date = todayString) + is StatsPeriod.Last12Months -> StatsDateRange.Preset(num = DAYS_IN_12_MONTHS, date = todayString) + is StatsPeriod.Custom -> StatsDateRange.Custom( + startDate = period.startDate.format(dateFormatter), + date = period.endDate.format(dateFormatter) + ) + } + } } /** @@ -797,3 +852,25 @@ data class MostViewedItemData( PERCENTAGE_NO_CHANGE } } + +/** + * Result wrapper for country views fetch operation. + */ +sealed class CountryViewsResult { + data class Success( + val countries: List, + val totalViews: Long, + val otherViews: Long + ) : CountryViewsResult() + data class Error(val message: String) : CountryViewsResult() +} + +/** + * Data for a single country view item from the repository layer. + */ +data class CountryViewItemData( + val countryCode: String, + val countryName: String, + val views: Long, + val flagIconUrl: String? +) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 13aaed4ff764..d57242191947 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1575,6 +1575,11 @@ Show All Top %d + + Countries + Locations + Views + Open Website Mark as Spam diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e31854b7f3d..2e54accd94a3 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 = '1119-c0b6b2248a27094599f6411ee52e68e7baafc469' +wordpress-rs = '1131-a97ec1789fd4b38f3e4542776c7b324370d41f7e' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2' From 4bc0c682b768ec224bb19f7b5ac8f6a35c21a420 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 29 Jan 2026 16:18:59 +0100 Subject: [PATCH 02/19] Handling on click show more --- WordPress/src/main/AndroidManifest.xml | 5 + .../android/ui/newstats/NewStatsActivity.kt | 11 + .../ui/newstats/countries/CountriesCard.kt | 62 ++- .../countries/CountriesCardUiState.kt | 3 +- .../countries/CountriesDetailActivity.kt | 420 ++++++++++++++++++ .../newstats/countries/CountriesViewModel.kt | 43 +- 6 files changed, 524 insertions(+), 20 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesDetailActivity.kt diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 1829576e77c7..8e9b6cef8405 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -117,6 +117,11 @@ android:theme="@style/WordPress.NoActionBar" android:exported="false" /> + + Unit, onRetry: () -> Unit, modifier: Modifier = Modifier ) { @@ -74,7 +77,7 @@ fun CountriesCard( ) { when (uiState) { is CountriesCardUiState.Loading -> LoadingContent() - is CountriesCardUiState.Loaded -> LoadedContent(uiState) + is CountriesCardUiState.Loaded -> LoadedContent(uiState, onShowAllClick) is CountriesCardUiState.Error -> ErrorContent(uiState, onRetry) } } @@ -172,7 +175,7 @@ private fun LoadingContent() { } @Composable -private fun LoadedContent(state: CountriesCardUiState.Loaded) { +private fun LoadedContent(state: CountriesCardUiState.Loaded, onShowAllClick: () -> Unit) { Column(modifier = Modifier.padding(CardPadding)) { // Title Text( @@ -220,17 +223,17 @@ private fun LoadedContent(state: CountriesCardUiState.Loaded) { } Spacer(modifier = Modifier.height(8.dp)) - // Country list - LazyColumn( - modifier = Modifier.height((state.countries.size * 56).coerceAtMost(280).dp) - ) { - itemsIndexed(state.countries) { index, country -> - CountryRow( - country = country, - showDivider = index < state.countries.size - 1 - ) - } + // Country list (capped at 10 items) + state.countries.forEachIndexed { index, country -> + CountryRow( + country = country, + showDivider = index < state.countries.size - 1 + ) } + + // Show All footer + Spacer(modifier = Modifier.height(12.dp)) + ShowAllFooter(onClick = onShowAllClick) } } } @@ -424,6 +427,31 @@ private fun CountryRow( } } +@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: CountriesCardUiState.Error, @@ -467,6 +495,7 @@ private fun CountriesCardLoadingPreview() { AppThemeM3 { CountriesCard( uiState = CountriesCardUiState.Loading, + onShowAllClick = {}, onRetry = {} ) } @@ -486,8 +515,10 @@ private fun CountriesCardLoadedPreview() { ), mapData = "['US',3464],['ES',556],['GB',522],['CA',485]", minViews = 485, - maxViews = 3464 + maxViews = 3464, + hasMoreItems = true ), + onShowAllClick = {}, onRetry = {} ) } @@ -499,6 +530,7 @@ private fun CountriesCardErrorPreview() { AppThemeM3 { CountriesCard( uiState = CountriesCardUiState.Error("Failed to load country data"), + onShowAllClick = {}, onRetry = {} ) } 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 745a9a3c1d65..ea7fb3604685 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 @@ -10,7 +10,8 @@ sealed class CountriesCardUiState { val countries: List, val mapData: String, val minViews: Long, - val maxViews: Long + val maxViews: Long, + val hasMoreItems: Boolean ) : CountriesCardUiState() data class Error(val message: String) : CountriesCardUiState() 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 new file mode 100644 index 000000000000..13017257cde6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesDetailActivity.kt @@ -0,0 +1,420 @@ +package org.wordpress.android.ui.newstats.countries + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.Base64 +import android.webkit.WebSettings +import android.webkit.WebView +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.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.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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +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.util.formatStatValue +import org.wordpress.android.util.extensions.getSerializableCompat +import java.io.Serializable + +private const val EXTRA_COUNTRIES = "extra_countries" +private const val EXTRA_MAP_DATA = "extra_map_data" +private const val EXTRA_MIN_VIEWS = "extra_min_views" +private const val EXTRA_MAX_VIEWS = "extra_max_views" +private const val MAP_ASPECT_RATIO = 8f / 5f + +@AndroidEntryPoint +class CountriesDetailActivity : BaseAppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + @Suppress("UNCHECKED_CAST") + val countries = intent.extras + ?.getSerializableCompat>(EXTRA_COUNTRIES) + ?: arrayListOf() + val mapData = intent.getStringExtra(EXTRA_MAP_DATA) ?: "" + val minViews = intent.getLongExtra(EXTRA_MIN_VIEWS, 0L) + val maxViews = intent.getLongExtra(EXTRA_MAX_VIEWS, 0L) + + setContent { + AppThemeM3 { + CountriesDetailScreen( + countries = countries, + mapData = mapData, + minViews = minViews, + maxViews = maxViews, + onBackPressed = onBackPressedDispatcher::onBackPressed + ) + } + } + } + + companion object { + fun start( + context: Context, + countries: List, + mapData: String, + minViews: Long, + maxViews: Long + ) { + val detailItems = countries.map { country -> + CountriesDetailItem( + countryCode = country.countryCode, + countryName = country.countryName, + views = country.views, + flagIconUrl = country.flagIconUrl + ) + } + val intent = Intent(context, CountriesDetailActivity::class.java).apply { + putExtra(EXTRA_COUNTRIES, ArrayList(detailItems)) + putExtra(EXTRA_MAP_DATA, mapData) + putExtra(EXTRA_MIN_VIEWS, minViews) + putExtra(EXTRA_MAX_VIEWS, maxViews) + } + context.startActivity(intent) + } + } +} + +data class CountriesDetailItem( + val countryCode: String, + val countryName: String, + val views: Long, + val flagIconUrl: String? +) : Serializable + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CountriesDetailScreen( + countries: List, + mapData: String, + minViews: Long, + maxViews: Long, + onBackPressed: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.stats_countries_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)) + // Map + CountryMap( + mapData = mapData, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(MAP_ASPECT_RATIO) + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Legend + MapLegend(minViews = minViews, maxViews = maxViews) + Spacer(modifier = Modifier.height(16.dp)) + } + + + itemsIndexed(countries) { index, country -> + DetailCountryRow( + position = index + 1, + country = country, + maxViews = countries.firstOrNull()?.views ?: 1L, + showDivider = index < countries.lastIndex + ) + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun CountryMap( + mapData: String, + modifier: Modifier = Modifier +) { + val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toHexString() + val colorHigh = MaterialTheme.colorScheme.primary.toHexString() + val backgroundColor = MaterialTheme.colorScheme.surface.toHexString() + val emptyColor = MaterialTheme.colorScheme.surfaceVariant.toHexString() + val viewsLabel = stringResource(R.string.stats_countries_views_header) + + val htmlPage = remember(mapData, colorLow, colorHigh, backgroundColor, emptyColor, viewsLabel) { + buildMapHtml(mapData, viewsLabel, colorLow, colorHigh, emptyColor, backgroundColor) + } + + AndroidView( + modifier = modifier.clip(RoundedCornerShape(8.dp)), + factory = { ctx -> + WebView(ctx).apply { + setBackgroundColor(Color.TRANSPARENT) + settings.javaScriptEnabled = true + settings.cacheMode = WebSettings.LOAD_NO_CACHE + } + }, + update = { webView -> + val base64Html = Base64.encodeToString(htmlPage.toByteArray(), Base64.DEFAULT) + webView.loadData(base64Html, "text/html; charset=UTF-8", "base64") + } + ) +} + +private fun buildMapHtml( + mapData: String, + viewsLabel: String, + colorLow: String, + colorHigh: String, + emptyColor: String, + backgroundColor: String +): String { + return """ + + + + + + +
+ + + """.trimIndent() +} + +@Composable +private fun MapLegend( + minViews: Long, + maxViews: Long +) { + val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + val colorHigh = MaterialTheme.colorScheme.primary + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatStatValue(minViews), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .weight(1f) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + Brush.horizontalGradient( + colors = listOf(colorLow, colorHigh) + ) + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formatStatValue(maxViews), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun DetailCountryRow( + position: Int, + country: CountriesDetailItem, + maxViews: Long, + showDivider: Boolean +) { + val percentage = if (maxViews > 0) country.views.toFloat() / maxViews.toFloat() else 0f + val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clip(RoundedCornerShape(8.dp)) + ) { + // Background bar representing the percentage + Box( + modifier = Modifier + .fillMaxWidth(fraction = percentage) + .height(56.dp) + .background(barColor) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Position number + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(32.dp) + ) + + // Flag icon + if (country.flagIconUrl != null) { + AsyncImage( + model = country.flagIconUrl, + contentDescription = country.countryName, + modifier = Modifier.size(24.dp) + ) + } else { + Box( + modifier = Modifier + .size(24.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + + // Country name + Text( + text = country.countryName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(12.dp)) + + // Views count + Text( + text = formatStatValue(country.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 1.dp + ) + } + } +} + +private fun androidx.compose.ui.graphics.Color.toHexString(): String { + val argb = this.toArgb() + return String.format("%06X", argb and 0xFFFFFF) +} + +@Preview(showBackground = true) +@Composable +private fun CountriesDetailScreenPreview() { + AppThemeM3 { + CountriesDetailScreen( + countries = listOf( + CountriesDetailItem("US", "United States", 3464, null), + CountriesDetailItem("ES", "Spain", 556, null), + CountriesDetailItem("GB", "United Kingdom", 522, null), + CountriesDetailItem("CA", "Canada", 485, null), + CountriesDetailItem("DE", "Germany", 412, null), + CountriesDetailItem("FR", "France", 387, null), + CountriesDetailItem("AU", "Australia", 298, null), + CountriesDetailItem("BR", "Brazil", 245, null), + CountriesDetailItem("IN", "India", 201, null), + CountriesDetailItem("MX", "Mexico", 156, null) + ), + mapData = "['US',3464],['ES',556],['GB',522],['CA',485]", + minViews = 156, + maxViews = 3464, + onBackPressed = {} + ) + } +} 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 87d8926a4943..b70608673659 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 @@ -15,6 +15,8 @@ import org.wordpress.android.ui.newstats.repository.CountryViewsResult import org.wordpress.android.ui.newstats.repository.StatsRepository import javax.inject.Inject +private const val CARD_MAX_ITEMS = 10 + @HiltViewModel class CountriesViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, @@ -30,6 +32,11 @@ class CountriesViewModel @Inject constructor( private var currentPeriod: StatsPeriod = StatsPeriod.Last7Days + private var allCountries: List = emptyList() + private var cachedMapData: String = "" + private var cachedMinViews: Long = 0L + private var cachedMaxViews: Long = 0L + init { loadData() } @@ -74,6 +81,15 @@ class CountriesViewModel @Inject constructor( } } + fun getDetailData(): CountriesDetailData { + return CountriesDetailData( + countries = allCountries, + mapData = cachedMapData, + minViews = cachedMinViews, + maxViews = cachedMaxViews + ) + } + private fun initializeRepository() { accountStore.accessToken?.let { token -> statsRepository.init(token) @@ -86,11 +102,16 @@ class CountriesViewModel @Inject constructor( when (val result = statsRepository.fetchCountryViews(siteId, currentPeriod)) { is CountryViewsResult.Success -> { if (result.countries.isEmpty()) { + allCountries = emptyList() + cachedMapData = "" + cachedMinViews = 0L + cachedMaxViews = 0L _uiState.value = CountriesCardUiState.Loaded( countries = emptyList(), mapData = "", minViews = 0, - maxViews = 0 + maxViews = 0, + hasMoreItems = false ) } else { val countries = result.countries.map { country -> @@ -107,11 +128,18 @@ class CountriesViewModel @Inject constructor( val minViews = countries.minOfOrNull { it.views } ?: 0L val maxViews = countries.maxOfOrNull { it.views } ?: 0L + // Store all data for detail screen + allCountries = countries + cachedMapData = mapData + cachedMinViews = if (minViews == maxViews) 0L else minViews + cachedMaxViews = maxViews + _uiState.value = CountriesCardUiState.Loaded( - countries = countries, + countries = countries.take(CARD_MAX_ITEMS), mapData = mapData, - minViews = if (minViews == maxViews) 0L else minViews, - maxViews = maxViews + minViews = cachedMinViews, + maxViews = cachedMaxViews, + hasMoreItems = countries.size > CARD_MAX_ITEMS ) } } @@ -131,3 +159,10 @@ class CountriesViewModel @Inject constructor( } } } + +data class CountriesDetailData( + val countries: List, + val mapData: String, + val minViews: Long, + val maxViews: Long +) From 5891b3850232aa74dc98e0f757c3c5fb764ba253 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 29 Jan 2026 16:25:35 +0100 Subject: [PATCH 03/19] UI fixes --- .../ui/newstats/countries/CountriesCard.kt | 62 +++++-- .../countries/CountriesDetailActivity.kt | 174 ++++++++++-------- 2 files changed, 144 insertions(+), 92 deletions(-) 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 a2107b291156..ee9a26b0f80d 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 @@ -17,8 +17,10 @@ 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 @@ -29,7 +31,6 @@ 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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -41,7 +42,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -224,11 +224,13 @@ private fun LoadedContent(state: CountriesCardUiState.Loaded, onShowAllClick: () Spacer(modifier = Modifier.height(8.dp)) // Country list (capped at 10 items) + val maxViews = state.countries.maxOfOrNull { it.views } ?: 1L state.countries.forEachIndexed { index, country -> - CountryRow( - country = country, - showDivider = index < state.countries.size - 1 - ) + val percentage = if (maxViews > 0) country.views.toFloat() / maxViews.toFloat() else 0f + CountryRow(country = country, percentage = percentage) + if (index < state.countries.lastIndex) { + Spacer(modifier = Modifier.height(4.dp)) + } } // Show All footer @@ -260,8 +262,7 @@ private fun CountryMap( mapData: String, modifier: Modifier = Modifier ) { - val context = LocalContext.current - val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toHexString() + val colorLow = MaterialTheme.colorScheme.primary.blendWithWhite(0.2f).toHexString() val colorHigh = MaterialTheme.colorScheme.primary.toHexString() val backgroundColor = MaterialTheme.colorScheme.surface.toHexString() val emptyColor = MaterialTheme.colorScheme.surfaceVariant.toHexString() @@ -370,13 +371,29 @@ private fun MapLegend( @Composable private fun CountryRow( country: CountryItem, - showDivider: Boolean + percentage: Float ) { - Column { + 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), + .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically ) { // Flag icon @@ -413,17 +430,10 @@ private fun CountryRow( Text( text = formatStatValue(country.views), style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) } - - if (showDivider) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - thickness = 1.dp - ) - } } } @@ -488,6 +498,20 @@ private fun androidx.compose.ui.graphics.Color.toHexString(): String { return String.format("%06X", argb and 0xFFFFFF) } +/** + * Blends this color with white based on the given ratio. + * Ratio of 0.0 returns white, ratio of 1.0 returns the original color. + */ +private fun androidx.compose.ui.graphics.Color.blendWithWhite(ratio: Float): androidx.compose.ui.graphics.Color { + val white = androidx.compose.ui.graphics.Color.White + return androidx.compose.ui.graphics.Color( + red = white.red + (this.red - white.red) * ratio, + green = white.green + (this.green - white.green) * ratio, + blue = white.blue + (this.blue - white.blue) * ratio, + alpha = 1f + ) +} + // Previews @Preview(showBackground = true) @Composable 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 13017257cde6..f6d9a088cb4b 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 @@ -13,9 +13,11 @@ 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 @@ -28,7 +30,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -40,10 +41,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -172,13 +171,39 @@ private fun CountriesDetailScreen( } + 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 + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + val maxViewsValue = countries.firstOrNull()?.views ?: 1L itemsIndexed(countries) { index, country -> + val percentage = if (maxViewsValue > 0) { + country.views.toFloat() / maxViewsValue.toFloat() + } else 0f DetailCountryRow( position = index + 1, country = country, - maxViews = countries.firstOrNull()?.views ?: 1L, - showDivider = index < countries.lastIndex + percentage = percentage ) + if (index < countries.lastIndex) { + Spacer(modifier = Modifier.height(4.dp)) + } } item { @@ -194,7 +219,7 @@ private fun CountryMap( mapData: String, modifier: Modifier = Modifier ) { - val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f).toHexString() + val colorLow = MaterialTheme.colorScheme.primary.blendWithWhite(0.2f).toHexString() val colorHigh = MaterialTheme.colorScheme.primary.toHexString() val backgroundColor = MaterialTheme.colorScheme.surface.toHexString() val emptyColor = MaterialTheme.colorScheme.surfaceVariant.toHexString() @@ -304,86 +329,75 @@ private fun MapLegend( private fun DetailCountryRow( position: Int, country: CountriesDetailItem, - maxViews: Long, - showDivider: Boolean + percentage: Float ) { - val percentage = if (maxViews > 0) country.views.toFloat() / maxViews.toFloat() else 0f val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) - Column { + 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() - .height(56.dp) - .clip(RoundedCornerShape(8.dp)) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Background bar representing the percentage - Box( - modifier = Modifier - .fillMaxWidth(fraction = percentage) - .height(56.dp) - .background(barColor) + // Position number + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(32.dp) ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Position number - Text( - text = position.toString(), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(32.dp) - ) - - // Flag icon - if (country.flagIconUrl != null) { - AsyncImage( - model = country.flagIconUrl, - contentDescription = country.countryName, - modifier = Modifier.size(24.dp) - ) - } else { - Box( - modifier = Modifier - .size(24.dp) - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(4.dp) - ) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - - // Country name - Text( - text = country.countryName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + // Flag icon + if (country.flagIconUrl != null) { + AsyncImage( + model = country.flagIconUrl, + contentDescription = country.countryName, + modifier = Modifier.size(24.dp) ) - Spacer(modifier = Modifier.width(12.dp)) - - // Views count - Text( - text = formatStatValue(country.views), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + } else { + Box( + modifier = Modifier + .size(24.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) ) } - } + Spacer(modifier = Modifier.width(12.dp)) + + // Country name + Text( + text = country.countryName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(12.dp)) - if (showDivider) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - thickness = 1.dp + // Views count + Text( + text = formatStatValue(country.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) } } @@ -394,6 +408,20 @@ private fun androidx.compose.ui.graphics.Color.toHexString(): String { return String.format("%06X", argb and 0xFFFFFF) } +/** + * Blends this color with white based on the given ratio. + * Ratio of 0.0 returns white, ratio of 1.0 returns the original color. + */ +private fun androidx.compose.ui.graphics.Color.blendWithWhite(ratio: Float): androidx.compose.ui.graphics.Color { + val white = androidx.compose.ui.graphics.Color.White + return androidx.compose.ui.graphics.Color( + red = white.red + (this.red - white.red) * ratio, + green = white.green + (this.green - white.green) * ratio, + blue = white.blue + (this.blue - white.blue) * ratio, + alpha = 1f + ) +} + @Preview(showBackground = true) @Composable private fun CountriesDetailScreenPreview() { From 1d6c7dd11b0983443e789a5874e4e8619c17bce9 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 29 Jan 2026 16:28:51 +0100 Subject: [PATCH 04/19] Adding missing datasource functions --- .../datasource/StatsDataSourceImpl.kt | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) 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 fdc752e6c59a..c92ce7cdf590 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 @@ -8,6 +8,8 @@ import uniffi.wp_api.StatsReferrersParams import uniffi.wp_api.StatsReferrersPeriod 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 org.wordpress.android.util.AppLog @@ -218,5 +220,70 @@ class StatsDataSourceImpl @Inject constructor( ReferrersDataResult.Error("Unknown error") } } + return ReferrersDataResult.Error("Referrers API not available") + } + + override suspend fun fetchCountryViews( + siteId: Long, + dateRange: StatsDateRange, + max: Int + ): CountryViewsDataResult { + val params = when (dateRange) { + is StatsDateRange.Preset -> StatsCountryViewsParams( + period = StatsCountryViewsPeriod.DAY, + date = dateRange.date, + num = dateRange.num.toUInt(), + max = max.coerceAtLeast(1).toUInt(), + locale = localeManagerWrapper.getLocale().toString(), + summarize = true + ) + is StatsDateRange.Custom -> StatsCountryViewsParams( + period = StatsCountryViewsPeriod.DAY, + date = dateRange.date, + startDate = dateRange.startDate, + max = max.coerceAtLeast(1).toUInt(), + locale = localeManagerWrapper.getLocale().toString(), + summarize = true + ) + } + + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsCountryViews().getStatsCountryViews( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + return when (result) { + is WpRequestResult.Success -> { + val summary = result.response.data.summary + val countryInfo = result.response.data.countryInfo.orEmpty() + + val countries = summary?.views.orEmpty().map { countryView -> + val code = countryView.countryCode.orEmpty() + val info = countryInfo[code] + CountryViewItem( + countryCode = code, + countryName = countryView.location ?: info?.countryFull.orEmpty(), + views = countryView.views?.toLong() ?: 0L, + flagIconUrl = info?.flagIcon + ) + } + + CountryViewsDataResult.Success( + CountryViewsData( + countries = countries, + totalViews = summary?.totalViews?.toLong() ?: 0L, + otherViews = summary?.otherViews?.toLong() ?: 0L + ) + ) + } + is WpRequestResult.WpError -> { + CountryViewsDataResult.Error(result.errorMessage) + } + else -> { + CountryViewsDataResult.Error("Unknown error") + } + } } } From 1bb43798abf96b674313554222b6f671b0f70781 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 11:13:00 +0100 Subject: [PATCH 05/19] Some adjustments --- .../android/ui/newstats/countries/CountriesCard.kt | 5 ++++- .../ui/newstats/countries/CountriesDetailActivity.kt | 12 +++++++++--- .../ui/newstats/countries/CountriesViewModel.kt | 1 - gradle/libs.versions.toml | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) 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 ee9a26b0f80d..df2704c29ddd 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 @@ -52,7 +52,9 @@ import coil.compose.AsyncImage import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.newstats.util.formatStatValue +import java.util.Locale +private const val RGB_MASK = 0xFFFFFF private val CardCornerRadius = 10.dp private val CardPadding = 16.dp private val CardMargin = 16.dp @@ -288,6 +290,7 @@ private fun CountryMap( ) } +@Suppress("LongParameterList") private fun buildMapHtml( mapData: String, viewsLabel: String, @@ -495,7 +498,7 @@ private fun ErrorContent( private fun androidx.compose.ui.graphics.Color.toHexString(): String { val argb = this.toArgb() - return String.format("%06X", argb and 0xFFFFFF) + return String.format(Locale.US, "%06X", argb and RGB_MASK) } /** 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 f6d9a088cb4b..eba1231145c5 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 @@ -12,7 +12,6 @@ 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 @@ -57,8 +56,10 @@ import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.util.extensions.getSerializableCompat import java.io.Serializable +import java.util.Locale private const val EXTRA_COUNTRIES = "extra_countries" +private const val RGB_MASK = 0xFFFFFF private const val EXTRA_MAP_DATA = "extra_map_data" private const val EXTRA_MIN_VIEWS = "extra_min_views" private const val EXTRA_MAX_VIEWS = "extra_max_views" @@ -122,7 +123,11 @@ data class CountriesDetailItem( val countryName: String, val views: Long, val flagIconUrl: String? -) : Serializable +) : Serializable { + companion object { + private const val serialVersionUID: Long = 1L + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -245,6 +250,7 @@ private fun CountryMap( ) } +@Suppress("LongParameterList") private fun buildMapHtml( mapData: String, viewsLabel: String, @@ -405,7 +411,7 @@ private fun DetailCountryRow( private fun androidx.compose.ui.graphics.Color.toHexString(): String { val argb = this.toArgb() - return String.format("%06X", argb and 0xFFFFFF) + return String.format(Locale.US, "%06X", argb and RGB_MASK) } /** 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 b70608673659..0d8cbab9f2ca 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 @@ -23,7 +23,6 @@ class CountriesViewModel @Inject constructor( private val accountStore: AccountStore, private val statsRepository: StatsRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(CountriesCardUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4f3a614c958..a34ac2fd5656 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 = '1131-a97ec1789fd4b38f3e4542776c7b324370d41f7e' +wordpress-rs = '1134-4bf0d13973795483d4c3e489d8605f51e3a58efe' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2' From 8d47db9bfb6959ba44768d22f75f41c16555ec39 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 11:34:20 +0100 Subject: [PATCH 06/19] Adding period comparision --- .../android/ui/newstats/NewStatsActivity.kt | 6 +- .../newstats/components/StatsSummaryCard.kt | 156 ++++++++++++++++++ .../ui/newstats/countries/CountriesCard.kt | 51 ++++-- .../countries/CountriesCardUiState.kt | 19 ++- .../countries/CountriesDetailActivity.kt | 113 ++++++++++--- .../newstats/countries/CountriesViewModel.kt | 51 +++++- .../mostviewed/MostViewedDetailActivity.kt | 87 +--------- .../ui/newstats/repository/StatsRepository.kt | 114 ++++++++++--- 8 files changed, 453 insertions(+), 144 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt 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 020f9ef6ff45..0c7ee54b2393 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 @@ -273,7 +273,11 @@ private fun TrafficTabContent( countries = detailData.countries, mapData = detailData.mapData, minViews = detailData.minViews, - maxViews = detailData.maxViews + maxViews = detailData.maxViews, + totalViews = detailData.totalViews, + totalViewsChange = detailData.totalViewsChange, + totalViewsChangePercent = detailData.totalViewsChangePercent, + dateRange = detailData.dateRange ) }, onRetry = countriesViewModel::onRetry diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt new file mode 100644 index 000000000000..540b006a384f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt @@ -0,0 +1,156 @@ +package org.wordpress.android.ui.newstats.components + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.newstats.StatsColors +import org.wordpress.android.ui.newstats.util.formatStatValue +import java.util.Locale +import kotlin.math.abs + +/** + * A reusable summary card component for stats detail screens. + * Displays total views with optional change indicator for period comparison. + * + * @param totalViews The total views count to display + * @param dateRange The date range string to display + * @param totalViewsChange Optional change value compared to previous period (null to hide indicator) + * @param totalViewsChangePercent Optional change percentage (required if totalViewsChange is provided) + * @param modifier Modifier for the card + */ +@Composable +fun StatsSummaryCard( + totalViews: Long, + dateRange: String, + totalViewsChange: Long? = null, + totalViewsChangePercent: Double? = null, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = stringResource(R.string.stats_views), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = dateRange, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatStatValue(totalViews), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + if (totalViewsChange != null && totalViewsChangePercent != null) { + ViewsChangeIndicator( + change = totalViewsChange, + changePercent = totalViewsChangePercent + ) + } + } + } + } +} + +@Composable +private fun ViewsChangeIndicator( + change: Long, + changePercent: Double +) { + if (change == 0L) return + + val isPositive = change > 0 + val sign = if (isPositive) "+" else "-" + val color = if (isPositive) StatsColors.ChangeBadgePositive else StatsColors.ChangeBadgeNegative + val arrowIcon = if (isPositive) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = arrowIcon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = color + ) + Text( + text = "$sign${formatStatValue(abs(change))} (${ + String.format(Locale.getDefault(), "%.1f%%", abs(changePercent)) + })", + style = MaterialTheme.typography.labelSmall, + color = color + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StatsSummaryCardWithChangePreview() { + AppThemeM3 { + StatsSummaryCard( + totalViews = 5400, + dateRange = "Last 7 days", + totalViewsChange = 69, + totalViewsChangePercent = 1.3 + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StatsSummaryCardWithoutChangePreview() { + AppThemeM3 { + StatsSummaryCard( + totalViews = 5400, + dateRange = "Last 7 days" + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StatsSummaryCardNegativeChangePreview() { + AppThemeM3 { + StatsSummaryCard( + totalViews = 4200, + dateRange = "Last 30 days", + totalViewsChange = -150, + totalViewsChangePercent = -3.4 + ) + } +} 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 df2704c29ddd..686e55a7bdaa 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 @@ -51,6 +51,7 @@ import androidx.compose.ui.viewinterop.AndroidView import coil.compose.AsyncImage import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.newstats.StatsColors import org.wordpress.android.ui.newstats.util.formatStatValue import java.util.Locale @@ -429,17 +430,45 @@ private fun CountryRow( ) Spacer(modifier = Modifier.width(12.dp)) - // Views count - Text( - text = formatStatValue(country.views), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) + // Views count and change + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatStatValue(country.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + ChangeIndicator(change = country.change) + } } } } +@Composable +private fun ChangeIndicator(change: CountryViewChange) { + val (text, color) = when (change) { + is CountryViewChange.Positive -> Pair( + "+${formatStatValue(change.value)} (${ + String.format(Locale.getDefault(), "%.1f%%", change.percentage) + })", + StatsColors.ChangeBadgePositive + ) + is CountryViewChange.Negative -> Pair( + "-${formatStatValue(change.value)} (${ + String.format(Locale.getDefault(), "%.1f%%", change.percentage) + })", + StatsColors.ChangeBadgeNegative + ) + is CountryViewChange.NoChange -> return + } + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color + ) +} + @Composable private fun ShowAllFooter(onClick: () -> Unit) { Row( @@ -535,10 +564,10 @@ private fun CountriesCardLoadedPreview() { CountriesCard( uiState = CountriesCardUiState.Loaded( countries = listOf( - CountryItem("US", "United States", 3464, null), - CountryItem("ES", "Spain", 556, null), - CountryItem("GB", "United Kingdom", 522, null), - CountryItem("CA", "Canada", 485, null) + CountryItem("US", "United States", 3464, null, CountryViewChange.Positive(124, 3.7)), + CountryItem("ES", "Spain", 556, null, CountryViewChange.Positive(45, 8.8)), + CountryItem("GB", "United Kingdom", 522, null, CountryViewChange.Negative(12, 2.2)), + CountryItem("CA", "Canada", 485, null, CountryViewChange.NoChange) ), mapData = "['US',3464],['ES',556],['GB',522],['CA',485]", minViews = 485, 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 ea7fb3604685..0338158aaa13 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 @@ -1,5 +1,7 @@ package org.wordpress.android.ui.newstats.countries +import java.io.Serializable + /** * UI State for the Countries stats card. */ @@ -24,10 +26,25 @@ sealed class CountriesCardUiState { * @param countryName Full country name * @param views Number of views from this country * @param flagIconUrl URL to the country flag icon + * @param change The change compared to the previous period */ data class CountryItem( val countryCode: String, val countryName: String, val views: Long, - val flagIconUrl: String? + val flagIconUrl: String?, + val change: CountryViewChange = CountryViewChange.NoChange ) + +/** + * Represents the change in views for a country compared to the previous period. + */ +sealed class CountryViewChange : Serializable { + data class Positive(val value: Long, val percentage: Double) : CountryViewChange() + data class Negative(val value: Long, val percentage: Double) : CountryViewChange() + data object NoChange : CountryViewChange() + + companion object { + private const val serialVersionUID: Long = 1L + } +} 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 eba1231145c5..204b400a2d25 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 @@ -12,6 +12,7 @@ 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 @@ -53,6 +54,8 @@ 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.StatsColors +import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.util.extensions.getSerializableCompat import java.io.Serializable @@ -63,6 +66,10 @@ private const val RGB_MASK = 0xFFFFFF private const val EXTRA_MAP_DATA = "extra_map_data" private const val EXTRA_MIN_VIEWS = "extra_min_views" private const val EXTRA_MAX_VIEWS = "extra_max_views" +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" private const val MAP_ASPECT_RATIO = 8f / 5f @AndroidEntryPoint @@ -77,6 +84,10 @@ class CountriesDetailActivity : BaseAppCompatActivity() { val mapData = intent.getStringExtra(EXTRA_MAP_DATA) ?: "" val minViews = intent.getLongExtra(EXTRA_MIN_VIEWS, 0L) val maxViews = intent.getLongExtra(EXTRA_MAX_VIEWS, 0L) + 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) ?: "" setContent { AppThemeM3 { @@ -85,6 +96,10 @@ class CountriesDetailActivity : BaseAppCompatActivity() { mapData = mapData, minViews = minViews, maxViews = maxViews, + totalViews = totalViews, + totalViewsChange = totalViewsChange, + totalViewsChangePercent = totalViewsChangePercent, + dateRange = dateRange, onBackPressed = onBackPressedDispatcher::onBackPressed ) } @@ -92,19 +107,25 @@ class CountriesDetailActivity : BaseAppCompatActivity() { } companion object { + @Suppress("LongParameterList") fun start( context: Context, countries: List, mapData: String, minViews: Long, - maxViews: Long + maxViews: Long, + totalViews: Long, + totalViewsChange: Long, + totalViewsChangePercent: Double, + dateRange: String ) { val detailItems = countries.map { country -> CountriesDetailItem( countryCode = country.countryCode, countryName = country.countryName, views = country.views, - flagIconUrl = country.flagIconUrl + flagIconUrl = country.flagIconUrl, + change = country.change ) } val intent = Intent(context, CountriesDetailActivity::class.java).apply { @@ -112,6 +133,10 @@ class CountriesDetailActivity : BaseAppCompatActivity() { putExtra(EXTRA_MAP_DATA, mapData) putExtra(EXTRA_MIN_VIEWS, minViews) putExtra(EXTRA_MAX_VIEWS, maxViews) + 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) } @@ -122,10 +147,11 @@ data class CountriesDetailItem( val countryCode: String, val countryName: String, val views: Long, - val flagIconUrl: String? + val flagIconUrl: String?, + val change: CountryViewChange = CountryViewChange.NoChange ) : Serializable { companion object { - private const val serialVersionUID: Long = 1L + private const val serialVersionUID: Long = 2L } } @@ -136,6 +162,10 @@ private fun CountriesDetailScreen( mapData: String, minViews: Long, maxViews: Long, + totalViews: Long, + totalViewsChange: Long, + totalViewsChangePercent: Double, + dateRange: String, onBackPressed: () -> Unit ) { Scaffold( @@ -161,6 +191,15 @@ private fun CountriesDetailScreen( ) { item { Spacer(modifier = Modifier.height(8.dp)) + // Summary card + StatsSummaryCard( + totalViews = totalViews, + dateRange = dateRange, + totalViewsChange = totalViewsChange, + totalViewsChangePercent = totalViewsChangePercent + ) + Spacer(modifier = Modifier.height(16.dp)) + // Map CountryMap( mapData = mapData, @@ -398,17 +437,45 @@ private fun DetailCountryRow( ) Spacer(modifier = Modifier.width(12.dp)) - // Views count - Text( - text = formatStatValue(country.views), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) + // Views count and change + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatStatValue(country.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + ChangeIndicator(change = country.change) + } } } } +@Composable +private fun ChangeIndicator(change: CountryViewChange) { + val (text, color) = when (change) { + is CountryViewChange.Positive -> Pair( + "+${formatStatValue(change.value)} (${ + String.format(Locale.getDefault(), "%.1f%%", change.percentage) + })", + StatsColors.ChangeBadgePositive + ) + is CountryViewChange.Negative -> Pair( + "-${formatStatValue(change.value)} (${ + String.format(Locale.getDefault(), "%.1f%%", change.percentage) + })", + StatsColors.ChangeBadgeNegative + ) + is CountryViewChange.NoChange -> return + } + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color + ) +} + private fun androidx.compose.ui.graphics.Color.toHexString(): String { val argb = this.toArgb() return String.format(Locale.US, "%06X", argb and RGB_MASK) @@ -434,20 +501,24 @@ private fun CountriesDetailScreenPreview() { AppThemeM3 { CountriesDetailScreen( countries = listOf( - CountriesDetailItem("US", "United States", 3464, null), - CountriesDetailItem("ES", "Spain", 556, null), - CountriesDetailItem("GB", "United Kingdom", 522, null), - CountriesDetailItem("CA", "Canada", 485, null), - CountriesDetailItem("DE", "Germany", 412, null), - CountriesDetailItem("FR", "France", 387, null), - CountriesDetailItem("AU", "Australia", 298, null), - CountriesDetailItem("BR", "Brazil", 245, null), - CountriesDetailItem("IN", "India", 201, null), - CountriesDetailItem("MX", "Mexico", 156, null) + CountriesDetailItem("US", "United States", 3464, null, CountryViewChange.Positive(124, 3.7)), + CountriesDetailItem("ES", "Spain", 556, null, CountryViewChange.Positive(45, 8.8)), + CountriesDetailItem("GB", "United Kingdom", 522, null, CountryViewChange.Negative(12, 2.2)), + CountriesDetailItem("CA", "Canada", 485, null, CountryViewChange.Positive(33, 7.3)), + CountriesDetailItem("DE", "Germany", 412, null, CountryViewChange.NoChange), + CountriesDetailItem("FR", "France", 387, null, CountryViewChange.Negative(8, 2.0)), + CountriesDetailItem("AU", "Australia", 298, null, CountryViewChange.Positive(21, 7.6)), + CountriesDetailItem("BR", "Brazil", 245, null, CountryViewChange.Positive(15, 6.5)), + CountriesDetailItem("IN", "India", 201, null, CountryViewChange.Negative(5, 2.4)), + CountriesDetailItem("MX", "Mexico", 156, null, CountryViewChange.Positive(12, 8.3)) ), mapData = "['US',3464],['ES',556],['GB',522],['CA',485]", minViews = 156, maxViews = 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/countries/CountriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt index 0d8cbab9f2ca..b208a3ab9bab 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 @@ -7,21 +7,27 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.wordpress.android.R import org.wordpress.android.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.repository.CountryViewItemData import org.wordpress.android.ui.newstats.repository.CountryViewsResult import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject +import kotlin.math.abs private const val CARD_MAX_ITEMS = 10 +private const val MONTH_ABBREVIATION_LENGTH = 3 @HiltViewModel class CountriesViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, - private val statsRepository: StatsRepository + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow(CountriesCardUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -35,6 +41,9 @@ class CountriesViewModel @Inject constructor( private var cachedMapData: String = "" private var cachedMinViews: Long = 0L private var cachedMaxViews: Long = 0L + private var cachedTotalViews: Long = 0L + private var cachedTotalViewsChange: Long = 0L + private var cachedTotalViewsChangePercent: Double = 0.0 init { loadData() @@ -85,7 +94,11 @@ class CountriesViewModel @Inject constructor( countries = allCountries, mapData = cachedMapData, minViews = cachedMinViews, - maxViews = cachedMaxViews + maxViews = cachedMaxViews, + totalViews = cachedTotalViews, + totalViewsChange = cachedTotalViewsChange, + totalViewsChangePercent = cachedTotalViewsChangePercent, + dateRange = currentPeriod.toDateRangeString(resourceProvider) ) } @@ -100,6 +113,10 @@ class CountriesViewModel @Inject constructor( when (val result = statsRepository.fetchCountryViews(siteId, currentPeriod)) { is CountryViewsResult.Success -> { + cachedTotalViews = result.totalViews + cachedTotalViewsChange = result.totalViewsChange + cachedTotalViewsChangePercent = result.totalViewsChangePercent + if (result.countries.isEmpty()) { allCountries = emptyList() cachedMapData = "" @@ -118,7 +135,8 @@ class CountriesViewModel @Inject constructor( countryCode = country.countryCode, countryName = country.countryName, views = country.views, - flagIconUrl = country.flagIconUrl + flagIconUrl = country.flagIconUrl, + change = country.toCountryViewChange() ) } @@ -148,6 +166,14 @@ class CountriesViewModel @Inject constructor( } } + private fun CountryViewItemData.toCountryViewChange(): CountryViewChange { + return when { + viewsChange > 0 -> CountryViewChange.Positive(viewsChange, abs(viewsChangePercent)) + viewsChange < 0 -> CountryViewChange.Negative(abs(viewsChange), abs(viewsChangePercent)) + else -> CountryViewChange.NoChange + } + } + /** * Builds the map data string for Google GeoChart. * Format: ['countryCode',views],['countryCode',views],... @@ -163,5 +189,22 @@ data class CountriesDetailData( val countries: List, val mapData: String, val minViews: Long, - val maxViews: Long + val maxViews: Long, + val totalViews: Long, + val totalViewsChange: Long, + val totalViewsChangePercent: Double, + val dateRange: String ) + +private fun StatsPeriod.toDateRangeString(resourceProvider: ResourceProvider): String { + return when (this) { + is StatsPeriod.Today -> resourceProvider.getString(R.string.stats_period_today) + is StatsPeriod.Last7Days -> resourceProvider.getString(R.string.stats_period_last_7_days) + is StatsPeriod.Last30Days -> resourceProvider.getString(R.string.stats_period_last_30_days) + is StatsPeriod.Last6Months -> resourceProvider.getString(R.string.stats_period_last_6_months) + is StatsPeriod.Last12Months -> resourceProvider.getString(R.string.stats_period_last_12_months) + is StatsPeriod.Custom -> "${startDate.dayOfMonth}-${endDate.dayOfMonth} ${ + endDate.month.name.take(MONTH_ABBREVIATION_LENGTH).lowercase().replaceFirstChar { it.uppercase() } + }" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt index 27130eee68e3..aae0693bf6d3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt @@ -24,8 +24,6 @@ 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.ChevronRight -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -47,10 +45,10 @@ 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.StatsColors -import org.wordpress.android.util.extensions.getSerializableCompat +import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.util.extensions.getSerializableCompat import java.util.Locale -import kotlin.math.abs private const val EXTRA_DATA_SOURCE = "extra_data_source" private const val EXTRA_ITEMS = "extra_items" @@ -149,11 +147,11 @@ private fun MostViewedDetailScreen( ) { item { Spacer(modifier = Modifier.height(8.dp)) - SummaryCard( + StatsSummaryCard( totalViews = totalViews, + dateRange = dateRange, totalViewsChange = totalViewsChange, - totalViewsChangePercent = totalViewsChangePercent, - dateRange = dateRange + totalViewsChangePercent = totalViewsChangePercent ) Spacer(modifier = Modifier.height(16.dp)) } @@ -181,81 +179,6 @@ private fun MostViewedDetailScreen( } } -@Composable -private fun SummaryCard( - totalViews: Long, - totalViewsChange: Long, - totalViewsChangePercent: Double, - dateRange: String -) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = stringResource(R.string.stats_views), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = dateRange, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Column(horizontalAlignment = Alignment.End) { - Text( - text = formatStatValue(totalViews), - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold - ) - TotalViewsChangeIndicator( - change = totalViewsChange, - changePercent = totalViewsChangePercent - ) - } - } - } -} - -@Composable -private fun TotalViewsChangeIndicator( - change: Long, - changePercent: Double -) { - if (change == 0L) return - - val isPositive = change > 0 - val sign = if (isPositive) "+" else "-" - val color = if (isPositive) StatsColors.ChangeBadgePositive else StatsColors.ChangeBadgeNegative - val arrowIcon = if (isPositive) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown - - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = arrowIcon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = color - ) - Text( - text = "$sign${formatStatValue(abs(change))} (${ - String.format(Locale.getDefault(), "%.1f%%", abs(changePercent)) - })", - style = MaterialTheme.typography.labelSmall, - color = color - ) - } -} - @Composable private fun ColumnHeaders(itemCount: Int) { Row( 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 59ea07a01d06..52cdeff9a5ab 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 @@ -669,56 +669,110 @@ class StatsRepository @Inject constructor( } /** - * Fetches country views stats for a specific site and period. + * Fetches country views 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 Country views data or error + * @return Country views data with comparison or error */ suspend fun fetchCountryViews( siteId: Long, period: StatsPeriod ): CountryViewsResult = withContext(ioDispatcher) { - val dateRange = calculateCountryViewsDateRange(period) + val (currentDateRange, previousDateRange) = calculateCountryViewsDateRanges(period) - val result = statsDataSource.fetchCountryViews(siteId, dateRange) + // Fetch both periods in parallel + val (currentResult, previousResult) = coroutineScope { + val currentDeferred = async { statsDataSource.fetchCountryViews(siteId, currentDateRange) } + val previousDeferred = async { statsDataSource.fetchCountryViews(siteId, previousDateRange) } + currentDeferred.await() to previousDeferred.await() + } - when (result) { + when (currentResult) { is CountryViewsDataResult.Success -> { + val previousCountriesMap = if (previousResult is CountryViewsDataResult.Success) { + previousResult.data.countries.associateBy { it.countryCode } + } else { + emptyMap() + } + + val totalViews = currentResult.data.totalViews + val previousTotalViews = if (previousResult is CountryViewsDataResult.Success) { + previousResult.data.totalViews + } 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 + CountryViewsResult.Success( - countries = result.data.countries.map { country -> + countries = currentResult.data.countries.map { country -> + val previousViews = previousCountriesMap[country.countryCode]?.views ?: 0L CountryViewItemData( countryCode = country.countryCode, countryName = country.countryName, views = country.views, - flagIconUrl = country.flagIconUrl + flagIconUrl = country.flagIconUrl, + previousViews = previousViews ) }, - totalViews = result.data.totalViews, - otherViews = result.data.otherViews + totalViews = totalViews, + otherViews = currentResult.data.otherViews, + totalViewsChange = totalChange, + totalViewsChangePercent = totalChangePercent ) } is CountryViewsDataResult.Error -> { - appLogWrapper.e(AppLog.T.STATS, "Error fetching country views: ${result.message}") - CountryViewsResult.Error(result.message) + appLogWrapper.e(AppLog.T.STATS, "Error fetching country views: ${currentResult.message}") + CountryViewsResult.Error(currentResult.message) } } } - private fun calculateCountryViewsDateRange(period: StatsPeriod): StatsDateRange { + private fun calculateCountryViewsDateRanges(period: StatsPeriod): Pair { val today = LocalDate.now() val todayString = today.format(dateFormatter) return when (period) { - is StatsPeriod.Today -> StatsDateRange.Preset(num = NUM_DAYS_TODAY, date = todayString) - is StatsPeriod.Last7Days -> StatsDateRange.Preset(num = DAYS_IN_7_DAYS, date = todayString) - is StatsPeriod.Last30Days -> StatsDateRange.Preset(num = DAYS_IN_30_DAYS, date = todayString) - is StatsPeriod.Last6Months -> StatsDateRange.Preset(num = DAYS_IN_6_MONTHS, date = todayString) - is StatsPeriod.Last12Months -> StatsDateRange.Preset(num = DAYS_IN_12_MONTHS, date = todayString) - is StatsPeriod.Custom -> StatsDateRange.Custom( - startDate = period.startDate.format(dateFormatter), - date = period.endDate.format(dateFormatter) - ) + is StatsPeriod.Today -> { + val yesterdayString = today.minusDays(NUM_DAYS_TODAY.toLong()).format(dateFormatter) + StatsDateRange.Preset(num = NUM_DAYS_TODAY, date = todayString) to + StatsDateRange.Preset(num = NUM_DAYS_TODAY, date = yesterdayString) + } + is StatsPeriod.Last7Days -> { + val previousEndString = today.minusDays(DAYS_IN_7_DAYS.toLong()).format(dateFormatter) + StatsDateRange.Preset(num = DAYS_IN_7_DAYS, date = todayString) to + StatsDateRange.Preset(num = DAYS_IN_7_DAYS, date = previousEndString) + } + is StatsPeriod.Last30Days -> { + val previousEndString = today.minusDays(DAYS_IN_30_DAYS.toLong()).format(dateFormatter) + StatsDateRange.Preset(num = DAYS_IN_30_DAYS, date = todayString) to + StatsDateRange.Preset(num = DAYS_IN_30_DAYS, date = previousEndString) + } + is StatsPeriod.Last6Months -> { + val previousEndString = today.minusDays(DAYS_IN_6_MONTHS.toLong()).format(dateFormatter) + StatsDateRange.Preset(num = DAYS_IN_6_MONTHS, date = todayString) to + StatsDateRange.Preset(num = DAYS_IN_6_MONTHS, date = previousEndString) + } + is StatsPeriod.Last12Months -> { + val previousEndString = today.minusDays(DAYS_IN_12_MONTHS.toLong()).format(dateFormatter) + StatsDateRange.Preset(num = DAYS_IN_12_MONTHS, date = todayString) to + StatsDateRange.Preset(num = DAYS_IN_12_MONTHS, date = previousEndString) + } + is StatsPeriod.Custom -> { + val daysBetween = ChronoUnit.DAYS.between(period.startDate, period.endDate).toInt() + 1 + val previousEnd = period.startDate.minusDays(1) + val previousStart = previousEnd.minusDays(daysBetween.toLong() - 1) + StatsDateRange.Custom( + startDate = period.startDate.format(dateFormatter), + date = period.endDate.format(dateFormatter) + ) to StatsDateRange.Custom( + startDate = previousStart.format(dateFormatter), + date = previousEnd.format(dateFormatter) + ) + } } } } @@ -860,7 +914,9 @@ sealed class CountryViewsResult { data class Success( val countries: List, val totalViews: Long, - val otherViews: Long + val otherViews: Long, + val totalViewsChange: Long, + val totalViewsChangePercent: Double ) : CountryViewsResult() data class Error(val message: String) : CountryViewsResult() } @@ -872,5 +928,15 @@ data class CountryViewItemData( val countryCode: String, val countryName: String, val views: Long, - val flagIconUrl: String? -) + val flagIconUrl: String?, + 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 + } +} From a8535d627e2575bde506866ba6db56835368c793 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 11:40:04 +0100 Subject: [PATCH 07/19] Views fix --- .../android/ui/newstats/repository/StatsRepository.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 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 52cdeff9a5ab..21635c32c70a 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 @@ -696,9 +696,10 @@ class StatsRepository @Inject constructor( emptyMap() } - val totalViews = currentResult.data.totalViews + // Calculate totalViews from countries list (API summary.totalViews may be null/0) + val totalViews = currentResult.data.countries.sumOf { it.views } val previousTotalViews = if (previousResult is CountryViewsDataResult.Success) { - previousResult.data.totalViews + previousResult.data.countries.sumOf { it.views } } else { 0L } From 907ab007b84057d1e646e42e47b9d33810f6c51a Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 11:43:31 +0100 Subject: [PATCH 08/19] tests --- .../StatsRepositoryCountryViewsTest.kt | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryCountryViewsTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryCountryViewsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryCountryViewsTest.kt new file mode 100644 index 000000000000..18c2f39b1314 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryCountryViewsTest.kt @@ -0,0 +1,254 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.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.CountryViewItem +import org.wordpress.android.ui.newstats.datasource.CountryViewsData +import org.wordpress.android.ui.newstats.datasource.CountryViewsDataResult +import org.wordpress.android.ui.newstats.datasource.StatsDataSource + +@ExperimentalCoroutinesApi +class StatsRepositoryCountryViewsTest : 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 fetchCountryViews, then success result is returned`() = test { + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(createCountryViewsData())) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Success::class.java) + val success = result as CountryViewsResult.Success + assertThat(success.countries).hasSize(2) + assertThat(success.countries[0].countryCode).isEqualTo(TEST_COUNTRY_CODE_1) + assertThat(success.countries[0].countryName).isEqualTo(TEST_COUNTRY_NAME_1) + assertThat(success.countries[0].views).isEqualTo(TEST_COUNTRY_VIEWS_1) + assertThat(success.countries[1].countryCode).isEqualTo(TEST_COUNTRY_CODE_2) + assertThat(success.countries[1].views).isEqualTo(TEST_COUNTRY_VIEWS_2) + } + + @Test + fun `given successful response, when fetchCountryViews, then totalViews is sum of country views`() = test { + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(createCountryViewsData())) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Success::class.java) + val success = result as CountryViewsResult.Success + // totalViews should be calculated from countries, not from API's summary field + assertThat(success.totalViews).isEqualTo(TEST_COUNTRY_VIEWS_1 + TEST_COUNTRY_VIEWS_2) + } + + @Test + fun `given API totalViews is zero, when fetchCountryViews, then totalViews is sum of country views`() = test { + // Simulate API returning 0 for totalViews but having country data + val countryViewsData = CountryViewsData( + countries = listOf( + CountryViewItem(TEST_COUNTRY_CODE_1, TEST_COUNTRY_NAME_1, TEST_COUNTRY_VIEWS_1, null), + CountryViewItem(TEST_COUNTRY_CODE_2, TEST_COUNTRY_NAME_2, TEST_COUNTRY_VIEWS_2, null) + ), + totalViews = 0L, // API returns 0 + otherViews = 0L + ) + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(countryViewsData)) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Success::class.java) + val success = result as CountryViewsResult.Success + // totalViews should still be calculated from country views + assertThat(success.totalViews).isEqualTo(TEST_COUNTRY_VIEWS_1 + TEST_COUNTRY_VIEWS_2) + } + + @Test + fun `given current and previous data, when fetchCountryViews, then change is calculated correctly`() = test { + val currentData = CountryViewsData( + countries = listOf( + CountryViewItem("US", "United States", 150, null), + CountryViewItem("UK", "United Kingdom", 100, null) + ), + totalViews = 250L, + otherViews = 0L + ) + val previousData = CountryViewsData( + countries = listOf( + CountryViewItem("US", "United States", 100, null), + CountryViewItem("UK", "United Kingdom", 100, null) + ), + totalViews = 200L, + otherViews = 0L + ) + + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(currentData)) + .thenReturn(CountryViewsDataResult.Success(previousData)) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Success::class.java) + val success = result as CountryViewsResult.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 country in both periods, when fetchCountryViews, then previousViews is set correctly`() = test { + val currentData = CountryViewsData( + countries = listOf(CountryViewItem("US", "United States", 150, null)), + totalViews = 150L, + otherViews = 0L + ) + val previousData = CountryViewsData( + countries = listOf(CountryViewItem("US", "United States", 100, null)), + totalViews = 100L, + otherViews = 0L + ) + + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(currentData)) + .thenReturn(CountryViewsDataResult.Success(previousData)) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Success::class.java) + val success = result as CountryViewsResult.Success + assertThat(success.countries[0].previousViews).isEqualTo(100) + assertThat(success.countries[0].viewsChange).isEqualTo(50) + assertThat(success.countries[0].viewsChangePercent).isEqualTo(50.0) + } + + @Test + fun `given new country not in previous period, when fetchCountryViews, then previousViews is zero`() = test { + val currentData = CountryViewsData( + countries = listOf(CountryViewItem("US", "United States", 100, null)), + totalViews = 100L, + otherViews = 0L + ) + val previousData = CountryViewsData( + countries = emptyList(), + totalViews = 0L, + otherViews = 0L + ) + + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(currentData)) + .thenReturn(CountryViewsDataResult.Success(previousData)) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Success::class.java) + val success = result as CountryViewsResult.Success + assertThat(success.countries[0].previousViews).isEqualTo(0) + assertThat(success.countries[0].viewsChange).isEqualTo(100) + assertThat(success.countries[0].viewsChangePercent).isEqualTo(100.0) + } + + @Test + fun `given previous fetch fails, when fetchCountryViews, then previousViews defaults to zero`() = test { + val currentData = CountryViewsData( + countries = listOf(CountryViewItem("US", "United States", 100, null)), + totalViews = 100L, + otherViews = 0L + ) + + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(currentData)) + .thenReturn(CountryViewsDataResult.Error("Network error")) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Success::class.java) + val success = result as CountryViewsResult.Success + assertThat(success.countries[0].previousViews).isEqualTo(0) + assertThat(success.totalViewsChange).isEqualTo(100) + assertThat(success.totalViewsChangePercent).isEqualTo(100.0) + } + + @Test + fun `given error response, when fetchCountryViews, then error result is returned`() = test { + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Error(ERROR_MESSAGE)) + + val result = repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + assertThat(result).isInstanceOf(CountryViewsResult.Error::class.java) + assertThat((result as CountryViewsResult.Error).message).isEqualTo(ERROR_MESSAGE) + } + + @Test + fun `when fetchCountryViews is called, then data source is called twice for comparison`() = test { + whenever(statsDataSource.fetchCountryViews(any(), any(), any())) + .thenReturn(CountryViewsDataResult.Success(createCountryViewsData())) + + repository.fetchCountryViews(TEST_SITE_ID, StatsPeriod.Last7Days) + + // Verify data source is called twice (current and previous period) + verify(statsDataSource, times(2)).fetchCountryViews( + siteId = eq(TEST_SITE_ID), + dateRange = any(), + max = any() + ) + } + + private fun createCountryViewsData() = CountryViewsData( + countries = listOf( + CountryViewItem( + countryCode = TEST_COUNTRY_CODE_1, + countryName = TEST_COUNTRY_NAME_1, + views = TEST_COUNTRY_VIEWS_1, + flagIconUrl = null + ), + CountryViewItem( + countryCode = TEST_COUNTRY_CODE_2, + countryName = TEST_COUNTRY_NAME_2, + views = TEST_COUNTRY_VIEWS_2, + flagIconUrl = null + ) + ), + totalViews = TEST_COUNTRY_VIEWS_1 + TEST_COUNTRY_VIEWS_2, + otherViews = 0L + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val ERROR_MESSAGE = "Test error message" + + private const val TEST_COUNTRY_CODE_1 = "US" + private const val TEST_COUNTRY_CODE_2 = "UK" + private const val TEST_COUNTRY_NAME_1 = "United States" + private const val TEST_COUNTRY_NAME_2 = "United Kingdom" + private const val TEST_COUNTRY_VIEWS_1 = 500L + private const val TEST_COUNTRY_VIEWS_2 = 300L + } +} From 9eeed38172a1d328d3746482144bb58f465f6fbb Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 11:53:30 +0100 Subject: [PATCH 09/19] Adding more tests --- .../countries/CountriesViewModelTest.kt | 541 ++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/countries/CountriesViewModelTest.kt 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 new file mode 100644 index 000000000000..7e84b539f1d7 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/countries/CountriesViewModelTest.kt @@ -0,0 +1,541 @@ +package org.wordpress.android.ui.newstats.countries + +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.repository.CountryViewItemData +import org.wordpress.android.ui.newstats.repository.CountryViewsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class CountriesViewModelTest : 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: CountriesViewModel + + 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 = CountriesViewModel( + 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(CountriesCardUiState.Error::class.java) + assertThat((state as CountriesCardUiState.Error).message).isEqualTo("No site selected") + } + + @Test + fun `when fetch fails, then error state is emitted`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(CountryViewsResult.Error(ERROR_MESSAGE)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(CountriesCardUiState.Error::class.java) + assertThat((state as CountriesCardUiState.Error).message).isEqualTo(ERROR_MESSAGE) + } + // endregion + + // region Success states + @Test + fun `when data loads successfully, then loaded state is emitted`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(CountriesCardUiState.Loaded::class.java) + } + + @Test + fun `when data loads, then countries contain correct values`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.countries).hasSize(2) + assertThat(state.countries[0].countryCode).isEqualTo(TEST_COUNTRY_CODE_1) + assertThat(state.countries[0].countryName).isEqualTo(TEST_COUNTRY_NAME_1) + assertThat(state.countries[0].views).isEqualTo(TEST_COUNTRY_VIEWS_1) + assertThat(state.countries[1].countryCode).isEqualTo(TEST_COUNTRY_CODE_2) + assertThat(state.countries[1].views).isEqualTo(TEST_COUNTRY_VIEWS_2) + } + + @Test + fun `when data loads, then map data is built correctly`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + val expectedMapData = "['$TEST_COUNTRY_CODE_1',$TEST_COUNTRY_VIEWS_1]," + + "['$TEST_COUNTRY_CODE_2',$TEST_COUNTRY_VIEWS_2]" + assertThat(state.mapData).isEqualTo(expectedMapData) + } + + @Test + fun `when data loads, then min and max views are calculated correctly`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.minViews).isEqualTo(TEST_COUNTRY_VIEWS_2) + assertThat(state.maxViews).isEqualTo(TEST_COUNTRY_VIEWS_1) + } + + @Test + fun `when all countries have same views, then minViews is zero`() = test { + val sameViewsResult = CountryViewsResult.Success( + countries = listOf( + CountryViewItemData("US", "United States", 100, null, 80), + CountryViewItemData("UK", "United Kingdom", 100, null, 90) + ), + totalViews = 200, + otherViews = 0, + totalViewsChange = 30, + totalViewsChangePercent = 17.6 + ) + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(sameViewsResult) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.minViews).isEqualTo(0L) + assertThat(state.maxViews).isEqualTo(100L) + } + + @Test + fun `when data loads with more than 10 countries, then only 10 are shown in card`() = test { + val manyCountries = (1..15).map { index -> + CountryViewItemData( + countryCode = "C$index", + countryName = "Country $index", + views = (100 - index).toLong(), + flagIconUrl = null, + previousViews = (90 - index).toLong() + ) + } + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn( + CountryViewsResult.Success( + countries = manyCountries, + totalViews = 1000, + otherViews = 0, + totalViewsChange = 100, + totalViewsChangePercent = 10.0 + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.countries).hasSize(10) + assertThat(state.hasMoreItems).isTrue() + } + + @Test + fun `when data loads with 10 or fewer countries, then hasMoreItems is false`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.hasMoreItems).isFalse() + } + + @Test + fun `when data loads with empty countries, then loaded state with empty list is emitted`() = test { + val emptyResult = CountryViewsResult.Success( + countries = emptyList(), + totalViews = 0, + otherViews = 0, + totalViewsChange = 0, + totalViewsChangePercent = 0.0 + ) + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(emptyResult) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.countries).isEmpty() + assertThat(state.mapData).isEmpty() + assertThat(state.hasMoreItems).isFalse() + } + // endregion + + // region Period changes + @Test + fun `when period changes, then data is reloaded`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onPeriodChanged(StatsPeriod.Last30Days) + advanceUntilIdle() + + verify(statsRepository, times(2)).fetchCountryViews(any(), any()) + verify(statsRepository).fetchCountryViews(eq(TEST_SITE_ID), eq(StatsPeriod.Last30Days)) + } + + @Test + fun `when same period is selected, then data is not reloaded`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onPeriodChanged(StatsPeriod.Last7Days) + advanceUntilIdle() + + // Should only be called once during init + verify(statsRepository, times(1)).fetchCountryViews(any(), any()) + } + // endregion + + // region Refresh + @Test + fun `when refresh is called, then isRefreshing becomes true then false`() = test { + whenever(statsRepository.fetchCountryViews(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.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + // Called twice: once during init, once during refresh + verify(statsRepository, times(2)).fetchCountryViews(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when refresh is called with no site, then data is not fetched`() = test { + whenever(statsRepository.fetchCountryViews(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)).fetchCountryViews(any(), any()) + } + // endregion + + // region Retry + @Test + fun `when onRetry is called, then data is reloaded`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onRetry() + advanceUntilIdle() + + // Called twice: once during init, once during retry + verify(statsRepository, times(2)).fetchCountryViews(any(), any()) + } + // endregion + + // region getDetailData + @Test + fun `when getDetailData is called, then returns cached data`() = test { + whenever(statsRepository.fetchCountryViews(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.countries).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 countries are returned not just card items`() = test { + val manyCountries = (1..15).map { index -> + CountryViewItemData( + countryCode = "C$index", + countryName = "Country $index", + views = (100 - index).toLong(), + flagIconUrl = null, + previousViews = (90 - index).toLong() + ) + } + whenever(resourceProvider.getString(R.string.stats_period_last_7_days)) + .thenReturn("Last 7 days") + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn( + CountryViewsResult.Success( + countries = manyCountries, + totalViews = 1000, + otherViews = 0, + totalViewsChange = 100, + totalViewsChangePercent = 10.0 + ) + ) + + initViewModel() + advanceUntilIdle() + + val detailData = viewModel.getDetailData() + // Card shows max 10, but detail data should have all 15 + assertThat(detailData.countries).hasSize(15) + } + + @Test + fun `when getDetailData is called, then map data is included`() = test { + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn(createSuccessResult()) + whenever(resourceProvider.getString(R.string.stats_period_last_7_days)) + .thenReturn("Last 7 days") + + initViewModel() + advanceUntilIdle() + + val detailData = viewModel.getDetailData() + + val expectedMapData = "['$TEST_COUNTRY_CODE_1',$TEST_COUNTRY_VIEWS_1]," + + "['$TEST_COUNTRY_CODE_2',$TEST_COUNTRY_VIEWS_2]" + assertThat(detailData.mapData).isEqualTo(expectedMapData) + assertThat(detailData.minViews).isEqualTo(TEST_COUNTRY_VIEWS_2) + assertThat(detailData.maxViews).isEqualTo(TEST_COUNTRY_VIEWS_1) + } + // endregion + + // region Change calculations + @Test + fun `when country has positive change, then CountryViewChange_Positive is returned`() = test { + val countries = listOf( + CountryViewItemData( + countryCode = "US", + countryName = "United States", + views = 150, + flagIconUrl = null, + previousViews = 100 + ) + ) + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn( + CountryViewsResult.Success( + countries = countries, + totalViews = 150, + otherViews = 0, + totalViewsChange = 50, + totalViewsChangePercent = 50.0 + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.countries[0].change).isInstanceOf(CountryViewChange.Positive::class.java) + val change = state.countries[0].change as CountryViewChange.Positive + assertThat(change.value).isEqualTo(50) + assertThat(change.percentage).isEqualTo(50.0) + } + + @Test + fun `when country has negative change, then CountryViewChange_Negative is returned`() = test { + val countries = listOf( + CountryViewItemData( + countryCode = "US", + countryName = "United States", + views = 50, + flagIconUrl = null, + previousViews = 100 + ) + ) + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn( + CountryViewsResult.Success( + countries = countries, + totalViews = 50, + otherViews = 0, + totalViewsChange = -50, + totalViewsChangePercent = -50.0 + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.countries[0].change).isInstanceOf(CountryViewChange.Negative::class.java) + val change = state.countries[0].change as CountryViewChange.Negative + assertThat(change.value).isEqualTo(50) + assertThat(change.percentage).isEqualTo(50.0) + } + + @Test + fun `when country has no change, then CountryViewChange_NoChange is returned`() = test { + val countries = listOf( + CountryViewItemData( + countryCode = "US", + countryName = "United States", + views = 100, + flagIconUrl = null, + previousViews = 100 + ) + ) + whenever(statsRepository.fetchCountryViews(any(), any())) + .thenReturn( + CountryViewsResult.Success( + countries = countries, + totalViews = 100, + otherViews = 0, + totalViewsChange = 0, + totalViewsChangePercent = 0.0 + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as CountriesCardUiState.Loaded + assertThat(state.countries[0].change).isEqualTo(CountryViewChange.NoChange) + } + // endregion + + // region Helper functions + private fun createSuccessResult() = CountryViewsResult.Success( + countries = listOf( + CountryViewItemData( + countryCode = TEST_COUNTRY_CODE_1, + countryName = TEST_COUNTRY_NAME_1, + views = TEST_COUNTRY_VIEWS_1, + flagIconUrl = null, + previousViews = TEST_COUNTRY_PREVIOUS_VIEWS_1 + ), + CountryViewItemData( + countryCode = TEST_COUNTRY_CODE_2, + countryName = TEST_COUNTRY_NAME_2, + views = TEST_COUNTRY_VIEWS_2, + flagIconUrl = null, + previousViews = TEST_COUNTRY_PREVIOUS_VIEWS_2 + ) + ), + totalViews = TEST_TOTAL_VIEWS, + otherViews = 0, + 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_COUNTRY_CODE_1 = "US" + private const val TEST_COUNTRY_CODE_2 = "UK" + private const val TEST_COUNTRY_NAME_1 = "United States" + private const val TEST_COUNTRY_NAME_2 = "United Kingdom" + private const val TEST_COUNTRY_VIEWS_1 = 500L + private const val TEST_COUNTRY_VIEWS_2 = 300L + private const val TEST_COUNTRY_PREVIOUS_VIEWS_1 = 400L + private const val TEST_COUNTRY_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 0fda461ece157a60429bc6a493d6e7abe199dd06 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 12:17:58 +0100 Subject: [PATCH 10/19] Improving map JS security --- .../components/StatsGeoChartWebView.kt | 163 ++++++++++++++++++ .../ui/newstats/countries/CountriesCard.kt | 108 ++---------- .../countries/CountriesDetailActivity.kt | 108 ++---------- 3 files changed, 185 insertions(+), 194 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsGeoChartWebView.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsGeoChartWebView.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsGeoChartWebView.kt new file mode 100644 index 000000000000..78cffd4a07ea --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsGeoChartWebView.kt @@ -0,0 +1,163 @@ +package org.wordpress.android.ui.newstats.components + +import android.annotation.SuppressLint +import android.graphics.Color +import android.net.http.SslError +import android.util.Base64 +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import org.wordpress.android.R +import java.util.Locale + +private const val RGB_MASK = 0xFFFFFF + +/** + * A WebView component for displaying Google GeoChart maps. + * + * Security measures implemented (following the pattern from MapViewHolder in old stats): + * - Custom WebViewClient with error handlers for graceful degradation + * - Handles SSL errors by hiding the view (does not proceed with insecure connections) + * - Handles resource errors gracefully + * - Loads HTML content as base64 data (not from external URLs) + * - JavaScript is enabled only for Google Charts functionality + * + * @param mapData The map data string in Google GeoChart format + * @param modifier Modifier for the WebView container + * @param onError Optional callback when an error occurs loading the map + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun StatsGeoChartWebView( + mapData: String, + modifier: Modifier = Modifier, + onError: (() -> Unit)? = null +) { + val context = LocalContext.current + // Use the same colors as old stats implementation (MapViewHolder pattern) + val colorLow = ContextCompat.getColor(context, R.color.stats_map_activity_low).toHexString() + val colorHigh = ContextCompat.getColor(context, R.color.stats_map_activity_high).toHexString() + val emptyColor = ContextCompat.getColor(context, R.color.stats_map_activity_empty).toHexString() + val backgroundColor = MaterialTheme.colorScheme.surface.toHexString() + val viewsLabel = stringResource(R.string.stats_countries_views_header) + + val htmlPage = remember(mapData, colorLow, colorHigh, backgroundColor, emptyColor, viewsLabel) { + buildGeoChartHtml(mapData, viewsLabel, colorLow, colorHigh, emptyColor, backgroundColor) + } + + AndroidView( + modifier = modifier.clip(RoundedCornerShape(8.dp)), + factory = { ctx -> + WebView(ctx).apply { + setBackgroundColor(Color.TRANSPARENT) + + // Set up WebViewClient with error handlers (matching old stats MapViewHolder pattern) + webViewClient = createWebViewClientWithErrorHandlers(onError) + + // Settings matching the old stats implementation + settings.javaScriptEnabled = true + settings.cacheMode = WebSettings.LOAD_NO_CACHE + } + }, + update = { webView -> + val base64Html = Base64.encodeToString(htmlPage.toByteArray(), Base64.DEFAULT) + webView.loadData(base64Html, "text/html; charset=UTF-8", "base64") + } + ) +} + +/** + * Creates a WebViewClient with error handlers for graceful degradation. + * This follows the same pattern as MapViewHolder in the old stats implementation. + */ +private fun createWebViewClientWithErrorHandlers(onError: (() -> Unit)?): WebViewClient { + return object : WebViewClient() { + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + super.onReceivedError(view, request, error) + // Trigger error callback for main frame errors + if (request?.isForMainFrame == true) { + onError?.invoke() + } + } + + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError? + ) { + // Do not proceed on SSL errors - this is the secure default behavior + super.onReceivedSslError(view, handler, error) + onError?.invoke() + } + } +} + +@Suppress("LongParameterList") +private fun buildGeoChartHtml( + mapData: String, + viewsLabel: String, + colorLow: String, + colorHigh: String, + emptyColor: String, + backgroundColor: String +): String { + return """ + + + + + + +
+ + + """.trimIndent() +} + +private fun androidx.compose.ui.graphics.Color.toHexString(): String { + return String.format(Locale.US, "%06X", (this.toArgb() and RGB_MASK)) +} + +private fun Int.toHexString(): String { + return String.format(Locale.US, "%06X", (this and RGB_MASK)) +} 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 686e55a7bdaa..333bed677926 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,10 +1,5 @@ package org.wordpress.android.ui.newstats.countries -import android.annotation.SuppressLint -import android.graphics.Color -import android.util.Base64 -import android.webkit.WebSettings -import android.webkit.WebView import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -35,27 +30,27 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import coil.compose.AsyncImage +import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.compose.ui.graphics.Color as ComposeColor import org.wordpress.android.ui.newstats.StatsColors import org.wordpress.android.ui.newstats.util.formatStatValue import java.util.Locale -private const val RGB_MASK = 0xFFFFFF private val CardCornerRadius = 10.dp private val CardPadding = 16.dp private val CardMargin = 16.dp @@ -259,88 +254,26 @@ private fun EmptyContent() { } } -@SuppressLint("SetJavaScriptEnabled") @Composable private fun CountryMap( mapData: String, modifier: Modifier = Modifier ) { - val colorLow = MaterialTheme.colorScheme.primary.blendWithWhite(0.2f).toHexString() - val colorHigh = MaterialTheme.colorScheme.primary.toHexString() - val backgroundColor = MaterialTheme.colorScheme.surface.toHexString() - val emptyColor = MaterialTheme.colorScheme.surfaceVariant.toHexString() - val viewsLabel = stringResource(R.string.stats_countries_views_header) - - val htmlPage = remember(mapData, colorLow, colorHigh, backgroundColor, emptyColor, viewsLabel) { - buildMapHtml(mapData, viewsLabel, colorLow, colorHigh, emptyColor, backgroundColor) - } - - AndroidView( - modifier = modifier.clip(RoundedCornerShape(8.dp)), - factory = { ctx -> - WebView(ctx).apply { - setBackgroundColor(Color.TRANSPARENT) - settings.javaScriptEnabled = true - settings.cacheMode = WebSettings.LOAD_NO_CACHE - } - }, - update = { webView -> - val base64Html = Base64.encodeToString(htmlPage.toByteArray(), Base64.DEFAULT) - webView.loadData(base64Html, "text/html; charset=UTF-8", "base64") - } + StatsGeoChartWebView( + mapData = mapData, + modifier = modifier ) } -@Suppress("LongParameterList") -private fun buildMapHtml( - mapData: String, - viewsLabel: String, - colorLow: String, - colorHigh: String, - emptyColor: String, - backgroundColor: String -): String { - return """ - - - - - - -
- - - """.trimIndent() -} - @Composable private fun MapLegend( minViews: Long, maxViews: Long ) { - val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - val colorHigh = MaterialTheme.colorScheme.primary + val context = LocalContext.current + // Use the same colors as the map (stats color resources) + val colorLow = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_low)) + val colorHigh = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_high)) Row( modifier = Modifier.fillMaxWidth(), @@ -525,25 +458,6 @@ private fun ErrorContent( } } -private fun androidx.compose.ui.graphics.Color.toHexString(): String { - val argb = this.toArgb() - return String.format(Locale.US, "%06X", argb and RGB_MASK) -} - -/** - * Blends this color with white based on the given ratio. - * Ratio of 0.0 returns white, ratio of 1.0 returns the original color. - */ -private fun androidx.compose.ui.graphics.Color.blendWithWhite(ratio: Float): androidx.compose.ui.graphics.Color { - val white = androidx.compose.ui.graphics.Color.White - return androidx.compose.ui.graphics.Color( - red = white.red + (this.red - white.red) * ratio, - green = white.green + (this.green - white.green) * ratio, - blue = white.blue + (this.blue - white.blue) * ratio, - alpha = 1f - ) -} - // Previews @Preview(showBackground = true) @Composable 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 204b400a2d25..cb0dda59bfed 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 @@ -1,13 +1,8 @@ package org.wordpress.android.ui.newstats.countries -import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.graphics.Color import android.os.Bundle -import android.util.Base64 -import android.webkit.WebSettings -import android.webkit.WebView import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -37,24 +32,25 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import coil.compose.AsyncImage +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R +import androidx.compose.ui.graphics.Color as ComposeColor import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.StatsColors +import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.util.extensions.getSerializableCompat @@ -62,7 +58,6 @@ import java.io.Serializable import java.util.Locale private const val EXTRA_COUNTRIES = "extra_countries" -private const val RGB_MASK = 0xFFFFFF private const val EXTRA_MAP_DATA = "extra_map_data" private const val EXTRA_MIN_VIEWS = "extra_min_views" private const val EXTRA_MAX_VIEWS = "extra_max_views" @@ -257,88 +252,26 @@ private fun CountriesDetailScreen( } } -@SuppressLint("SetJavaScriptEnabled") @Composable private fun CountryMap( mapData: String, modifier: Modifier = Modifier ) { - val colorLow = MaterialTheme.colorScheme.primary.blendWithWhite(0.2f).toHexString() - val colorHigh = MaterialTheme.colorScheme.primary.toHexString() - val backgroundColor = MaterialTheme.colorScheme.surface.toHexString() - val emptyColor = MaterialTheme.colorScheme.surfaceVariant.toHexString() - val viewsLabel = stringResource(R.string.stats_countries_views_header) - - val htmlPage = remember(mapData, colorLow, colorHigh, backgroundColor, emptyColor, viewsLabel) { - buildMapHtml(mapData, viewsLabel, colorLow, colorHigh, emptyColor, backgroundColor) - } - - AndroidView( - modifier = modifier.clip(RoundedCornerShape(8.dp)), - factory = { ctx -> - WebView(ctx).apply { - setBackgroundColor(Color.TRANSPARENT) - settings.javaScriptEnabled = true - settings.cacheMode = WebSettings.LOAD_NO_CACHE - } - }, - update = { webView -> - val base64Html = Base64.encodeToString(htmlPage.toByteArray(), Base64.DEFAULT) - webView.loadData(base64Html, "text/html; charset=UTF-8", "base64") - } + StatsGeoChartWebView( + mapData = mapData, + modifier = modifier ) } -@Suppress("LongParameterList") -private fun buildMapHtml( - mapData: String, - viewsLabel: String, - colorLow: String, - colorHigh: String, - emptyColor: String, - backgroundColor: String -): String { - return """ - - - - - - -
- - - """.trimIndent() -} - @Composable private fun MapLegend( minViews: Long, maxViews: Long ) { - val colorLow = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - val colorHigh = MaterialTheme.colorScheme.primary + val context = LocalContext.current + // Use the same colors as the map (stats color resources) + val colorLow = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_low)) + val colorHigh = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_high)) Row( modifier = Modifier.fillMaxWidth(), @@ -476,25 +409,6 @@ private fun ChangeIndicator(change: CountryViewChange) { ) } -private fun androidx.compose.ui.graphics.Color.toHexString(): String { - val argb = this.toArgb() - return String.format(Locale.US, "%06X", argb and RGB_MASK) -} - -/** - * Blends this color with white based on the given ratio. - * Ratio of 0.0 returns white, ratio of 1.0 returns the original color. - */ -private fun androidx.compose.ui.graphics.Color.blendWithWhite(ratio: Float): androidx.compose.ui.graphics.Color { - val white = androidx.compose.ui.graphics.Color.White - return androidx.compose.ui.graphics.Color( - red = white.red + (this.red - white.red) * ratio, - green = white.green + (this.green - white.green) * ratio, - blue = white.blue + (this.blue - white.blue) * ratio, - alpha = 1f - ) -} - @Preview(showBackground = true) @Composable private fun CountriesDetailScreenPreview() { From 9f980a580439bb44017ea24cb3fc7ff25ad4c480 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 12:22:25 +0100 Subject: [PATCH 11/19] Extracting some duplicated code --- .../components/StatsChangeIndicator.kt | 41 ++++++++++ .../ui/newstats/components/StatsMapLegend.kt | 72 +++++++++++++++++ .../ui/newstats/countries/CountriesCard.kt | 76 +----------------- .../countries/CountriesDetailActivity.kt | 77 +------------------ .../datasource/StatsDataSourceImpl.kt | 1 - 5 files changed, 121 insertions(+), 146 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsChangeIndicator.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsMapLegend.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsChangeIndicator.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsChangeIndicator.kt new file mode 100644 index 000000000000..7ceac9bfeb52 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsChangeIndicator.kt @@ -0,0 +1,41 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import org.wordpress.android.ui.newstats.StatsColors +import org.wordpress.android.ui.newstats.countries.CountryViewChange +import org.wordpress.android.ui.newstats.util.formatStatValue +import java.util.Locale + +/** + * 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: CountryViewChange) { + val (text, color) = when (change) { + is CountryViewChange.Positive -> Pair( + "+${formatStatValue(change.value)} (${ + String.format(Locale.getDefault(), "%.1f%%", change.percentage) + })", + StatsColors.ChangeBadgePositive + ) + is CountryViewChange.Negative -> Pair( + "-${formatStatValue(change.value)} (${ + String.format(Locale.getDefault(), "%.1f%%", change.percentage) + })", + StatsColors.ChangeBadgeNegative + ) + is CountryViewChange.NoChange -> return + } + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsMapLegend.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsMapLegend.kt new file mode 100644 index 000000000000..1fc5b9f982a3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsMapLegend.kt @@ -0,0 +1,72 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.util.formatStatValue +import androidx.compose.ui.graphics.Color as ComposeColor + +/** + * A shared map legend component that displays a gradient bar with min/max values. + * Uses the same colors as the GeoChart map (stats_map_activity_low/high). + * + * @param minViews The minimum views value to display + * @param maxViews The maximum views value to display + * @param modifier Optional modifier for the legend + */ +@Composable +fun StatsMapLegend( + minViews: Long, + maxViews: Long, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + // Use the same colors as the map (stats color resources) + val colorLow = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_low)) + val colorHigh = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_high)) + + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatStatValue(minViews), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .weight(1f) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + Brush.horizontalGradient( + colors = listOf(colorLow, colorHigh) + ) + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formatStatValue(maxViews), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} 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 333bed677926..970c191e6561 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 @@ -41,15 +41,12 @@ 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.ui.newstats.components.StatsChangeIndicator import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView +import org.wordpress.android.ui.newstats.components.StatsMapLegend import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat -import androidx.compose.ui.graphics.Color as ComposeColor -import org.wordpress.android.ui.newstats.StatsColors import org.wordpress.android.ui.newstats.util.formatStatValue -import java.util.Locale private val CardCornerRadius = 10.dp private val CardPadding = 16.dp @@ -197,7 +194,7 @@ private fun LoadedContent(state: CountriesCardUiState.Loaded, onShowAllClick: () Spacer(modifier = Modifier.height(12.dp)) // Legend - MapLegend( + StatsMapLegend( minViews = state.minViews, maxViews = state.maxViews ) @@ -265,46 +262,6 @@ private fun CountryMap( ) } -@Composable -private fun MapLegend( - minViews: Long, - maxViews: Long -) { - val context = LocalContext.current - // Use the same colors as the map (stats color resources) - val colorLow = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_low)) - val colorHigh = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_high)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = formatStatValue(minViews), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .weight(1f) - .height(8.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - Brush.horizontalGradient( - colors = listOf(colorLow, colorHigh) - ) - ) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = formatStatValue(maxViews), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - @Composable private fun CountryRow( country: CountryItem, @@ -371,37 +328,12 @@ private fun CountryRow( fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) - ChangeIndicator(change = country.change) + StatsChangeIndicator(change = country.change) } } } } -@Composable -private fun ChangeIndicator(change: CountryViewChange) { - val (text, color) = when (change) { - is CountryViewChange.Positive -> Pair( - "+${formatStatValue(change.value)} (${ - String.format(Locale.getDefault(), "%.1f%%", change.percentage) - })", - StatsColors.ChangeBadgePositive - ) - is CountryViewChange.Negative -> Pair( - "-${formatStatValue(change.value)} (${ - String.format(Locale.getDefault(), "%.1f%%", change.percentage) - })", - StatsColors.ChangeBadgeNegative - ) - is CountryViewChange.NoChange -> return - } - - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = color - ) -} - @Composable private fun ShowAllFooter(onClick: () -> Unit) { Row( 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 cb0dda59bfed..1d11795effe3 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 @@ -35,27 +35,23 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush 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 androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R -import androidx.compose.ui.graphics.Color as ComposeColor import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity -import org.wordpress.android.ui.newstats.StatsColors +import org.wordpress.android.ui.newstats.components.StatsChangeIndicator import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView +import org.wordpress.android.ui.newstats.components.StatsMapLegend import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.util.extensions.getSerializableCompat import java.io.Serializable -import java.util.Locale private const val EXTRA_COUNTRIES = "extra_countries" private const val EXTRA_MAP_DATA = "extra_map_data" @@ -205,7 +201,7 @@ private fun CountriesDetailScreen( Spacer(modifier = Modifier.height(12.dp)) // Legend - MapLegend(minViews = minViews, maxViews = maxViews) + StatsMapLegend(minViews = minViews, maxViews = maxViews) Spacer(modifier = Modifier.height(16.dp)) } @@ -263,46 +259,6 @@ private fun CountryMap( ) } -@Composable -private fun MapLegend( - minViews: Long, - maxViews: Long -) { - val context = LocalContext.current - // Use the same colors as the map (stats color resources) - val colorLow = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_low)) - val colorHigh = ComposeColor(ContextCompat.getColor(context, R.color.stats_map_activity_high)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = formatStatValue(minViews), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .weight(1f) - .height(8.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - Brush.horizontalGradient( - colors = listOf(colorLow, colorHigh) - ) - ) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = formatStatValue(maxViews), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - @Composable private fun DetailCountryRow( position: Int, @@ -378,37 +334,12 @@ private fun DetailCountryRow( fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) - ChangeIndicator(change = country.change) + StatsChangeIndicator(change = country.change) } } } } -@Composable -private fun ChangeIndicator(change: CountryViewChange) { - val (text, color) = when (change) { - is CountryViewChange.Positive -> Pair( - "+${formatStatValue(change.value)} (${ - String.format(Locale.getDefault(), "%.1f%%", change.percentage) - })", - StatsColors.ChangeBadgePositive - ) - is CountryViewChange.Negative -> Pair( - "-${formatStatValue(change.value)} (${ - String.format(Locale.getDefault(), "%.1f%%", change.percentage) - })", - StatsColors.ChangeBadgeNegative - ) - is CountryViewChange.NoChange -> return - } - - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = color - ) -} - @Preview(showBackground = true) @Composable private fun CountriesDetailScreenPreview() { 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 c92ce7cdf590..86588ea2f64b 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 @@ -220,7 +220,6 @@ class StatsDataSourceImpl @Inject constructor( ReferrersDataResult.Error("Unknown error") } } - return ReferrersDataResult.Error("Referrers API not available") } override suspend fun fetchCountryViews( From bc49b8d12318e236b421140bf3556cb570c8c6a6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 12:27:50 +0100 Subject: [PATCH 12/19] Improving bars drawing --- .../android/ui/newstats/countries/CountriesCard.kt | 6 ++++-- .../ui/newstats/countries/CountriesCardUiState.kt | 1 + .../ui/newstats/countries/CountriesDetailActivity.kt | 10 +++++++--- .../ui/newstats/countries/CountriesViewModel.kt | 8 +++++++- .../android/ui/newstats/mostviewed/MostViewedCard.kt | 12 +++++++----- .../ui/newstats/mostviewed/MostViewedCardUiState.kt | 3 ++- .../newstats/mostviewed/MostViewedDetailActivity.kt | 11 ++++++++--- .../ui/newstats/mostviewed/MostViewedViewModel.kt | 9 +++++++-- 8 files changed, 43 insertions(+), 17 deletions(-) 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 970c191e6561..018f86f7f437 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 @@ -219,9 +219,10 @@ private fun LoadedContent(state: CountriesCardUiState.Loaded, onShowAllClick: () Spacer(modifier = Modifier.height(8.dp)) // Country list (capped at 10 items) - val maxViews = state.countries.maxOfOrNull { it.views } ?: 1L state.countries.forEachIndexed { index, country -> - val percentage = if (maxViews > 0) country.views.toFloat() / maxViews.toFloat() else 0f + val percentage = if (state.maxViewsForBar > 0) { + country.views.toFloat() / state.maxViewsForBar.toFloat() + } else 0f CountryRow(country = country, percentage = percentage) if (index < state.countries.lastIndex) { Spacer(modifier = Modifier.height(4.dp)) @@ -418,6 +419,7 @@ private fun CountriesCardLoadedPreview() { mapData = "['US',3464],['ES',556],['GB',522],['CA',485]", minViews = 485, maxViews = 3464, + maxViewsForBar = 3464, hasMoreItems = true ), onShowAllClick = {}, 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 0338158aaa13..8141c6c972ad 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 @@ -13,6 +13,7 @@ sealed class CountriesCardUiState { val mapData: String, val minViews: Long, val maxViews: Long, + val maxViewsForBar: Long, val hasMoreItems: Boolean ) : CountriesCardUiState() 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 1d11795effe3..aa01b1adeba6 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 @@ -79,6 +79,8 @@ class CountriesDetailActivity : BaseAppCompatActivity() { 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 = countries.firstOrNull()?.views ?: 1L setContent { AppThemeM3 { @@ -87,6 +89,7 @@ class CountriesDetailActivity : BaseAppCompatActivity() { mapData = mapData, minViews = minViews, maxViews = maxViews, + maxViewsForBar = maxViewsForBar, totalViews = totalViews, totalViewsChange = totalViewsChange, totalViewsChangePercent = totalViewsChangePercent, @@ -153,6 +156,7 @@ private fun CountriesDetailScreen( mapData: String, minViews: Long, maxViews: Long, + maxViewsForBar: Long, totalViews: Long, totalViewsChange: Long, totalViewsChangePercent: Double, @@ -226,10 +230,9 @@ private fun CountriesDetailScreen( Spacer(modifier = Modifier.height(8.dp)) } - val maxViewsValue = countries.firstOrNull()?.views ?: 1L itemsIndexed(countries) { index, country -> - val percentage = if (maxViewsValue > 0) { - country.views.toFloat() / maxViewsValue.toFloat() + val percentage = if (maxViewsForBar > 0) { + country.views.toFloat() / maxViewsForBar.toFloat() } else 0f DetailCountryRow( position = index + 1, @@ -360,6 +363,7 @@ private fun CountriesDetailScreenPreview() { mapData = "['US',3464],['ES',556],['GB',522],['CA',485]", minViews = 156, maxViews = 3464, + maxViewsForBar = 3464, totalViews = 6726, totalViewsChange = 225, totalViewsChangePercent = 3.5, 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 b208a3ab9bab..cf595583fb67 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 @@ -127,6 +127,7 @@ class CountriesViewModel @Inject constructor( mapData = "", minViews = 0, maxViews = 0, + maxViewsForBar = 0, hasMoreItems = false ) } else { @@ -151,11 +152,16 @@ class CountriesViewModel @Inject constructor( cachedMinViews = if (minViews == maxViews) 0L else minViews cachedMaxViews = maxViews + // 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 + _uiState.value = CountriesCardUiState.Loaded( - countries = countries.take(CARD_MAX_ITEMS), + countries = cardCountries, mapData = mapData, minViews = cachedMinViews, maxViews = cachedMaxViews, + maxViewsForBar = maxViewsForBar, hasMoreItems = countries.size > CARD_MAX_ITEMS ) } 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 ec1a9877f265..6c0c2c299ae0 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 @@ -162,8 +162,6 @@ private fun LoadedContent( onDataSourceChanged: (MostViewedDataSource) -> Unit, onShowAllClick: () -> Unit ) { - val maxViews = state.items.maxOfOrNull { it.views } ?: 1L - Column( modifier = Modifier .fillMaxWidth() @@ -177,7 +175,9 @@ private fun LoadedContent( ) Spacer(modifier = Modifier.height(8.dp)) state.items.forEachIndexed { index, item -> - val percentage = if (maxViews > 0) item.views.toFloat() / maxViews.toFloat() else 0f + val percentage = if (state.maxViewsForBar > 0) { + item.views.toFloat() / state.maxViewsForBar.toFloat() + } else 0f MostViewedItemRow(item = item, percentage = percentage) if (index < state.items.lastIndex) { Spacer(modifier = Modifier.height(4.dp)) @@ -494,7 +494,8 @@ private fun MostViewedCardLoadedPreview() { change = MostViewedChange.Positive(23, 191.7), isHighlighted = false ) - ) + ), + maxViewsForBar = 417 ), onDataSourceChanged = {}, onShowAllClick = {}, @@ -540,7 +541,8 @@ private fun MostViewedCardLoadedDarkPreview() { change = MostViewedChange.NoChange, isHighlighted = false ) - ) + ), + maxViewsForBar = 417 ), onDataSourceChanged = {}, onShowAllClick = {}, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt index e0f4a6ff6d7e..714ede24f527 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt @@ -20,7 +20,8 @@ sealed class MostViewedCardUiState { data class Loaded( val selectedDataSource: MostViewedDataSource, - val items: List + val items: List, + val maxViewsForBar: Long ) : MostViewedCardUiState() data class Error(val message: String) : MostViewedCardUiState() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt index aae0693bf6d3..5e971206577c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt @@ -71,12 +71,15 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { 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 = items.firstOrNull()?.views ?: 1L setContent { AppThemeM3 { MostViewedDetailScreen( dataSource = dataSource, items = items, + maxViewsForBar = maxViewsForBar, totalViews = totalViews, totalViewsChange = totalViewsChange, totalViewsChangePercent = totalViewsChangePercent, @@ -116,6 +119,7 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { private fun MostViewedDetailScreen( dataSource: MostViewedDataSource, items: List, + maxViewsForBar: Long, totalViews: Long, totalViewsChange: Long, totalViewsChangePercent: Double, @@ -165,7 +169,7 @@ private fun MostViewedDetailScreen( DetailItemRow( position = index + 1, item = item, - maxViews = items.firstOrNull()?.views ?: 1L + maxViewsForBar = maxViewsForBar ) if (index < items.lastIndex) { Spacer(modifier = Modifier.height(4.dp)) @@ -202,9 +206,9 @@ private fun ColumnHeaders(itemCount: Int) { private fun DetailItemRow( position: Int, item: MostViewedDetailItem, - maxViews: Long + maxViewsForBar: Long ) { - val percentage = if (maxViews > 0) item.views.toFloat() / maxViews.toFloat() else 0f + val percentage = if (maxViewsForBar > 0) item.views.toFloat() / maxViewsForBar.toFloat() else 0f val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) Box( @@ -312,6 +316,7 @@ private fun MostViewedDetailScreenPreview() { MostViewedDetailItem(5, "AI Tools & Resource Hub", 72, MostViewedChange.Positive(31, 75.6)) ), + maxViewsForBar = 998, totalViews = 5400, totalViewsChange = 69, totalViewsChangePercent = 1.3, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt index 3425030172c9..fff38c8eb73a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt @@ -129,9 +129,13 @@ class MostViewedViewModel @Inject constructor( change = item.toMostViewedChange() ) } + val cardItems = allItems.take(CARD_MAX_ITEMS) + // For bar percentage, use first item's views (list is sorted by views descending) + val maxViewsForBar = cardItems.firstOrNull()?.views ?: 1L + _uiState.value = MostViewedCardUiState.Loaded( selectedDataSource = currentDataSource, - items = allItems.take(CARD_MAX_ITEMS).mapIndexed { index, item -> + items = cardItems.mapIndexed { index, item -> MostViewedItem( id = item.id, title = item.title, @@ -139,7 +143,8 @@ class MostViewedViewModel @Inject constructor( change = item.change, isHighlighted = index == 0 ) - } + }, + maxViewsForBar = maxViewsForBar ) } is MostViewedResult.Error -> { From 099b6bdbd2f7926986c06a236cacf2d07983ca50 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 12:30:46 +0100 Subject: [PATCH 13/19] Using Parcelable instead of Serializable --- .../countries/CountriesCardUiState.kt | 12 +++++----- .../countries/CountriesDetailActivity.kt | 15 +++++-------- .../mostviewed/MostViewedCardUiState.kt | 22 +++++++++---------- .../mostviewed/MostViewedDetailActivity.kt | 4 ++-- 4 files changed, 24 insertions(+), 29 deletions(-) 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 8141c6c972ad..536f90d5b6f5 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 @@ -1,6 +1,7 @@ package org.wordpress.android.ui.newstats.countries -import java.io.Serializable +import android.os.Parcelable +import kotlinx.parcelize.Parcelize /** * UI State for the Countries stats card. @@ -40,12 +41,11 @@ data class CountryItem( /** * Represents the change in views for a country compared to the previous period. */ -sealed class CountryViewChange : Serializable { +sealed class CountryViewChange : Parcelable { + @Parcelize data class Positive(val value: Long, val percentage: Double) : CountryViewChange() + @Parcelize data class Negative(val value: Long, val percentage: Double) : CountryViewChange() + @Parcelize data object NoChange : CountryViewChange() - - companion object { - private const val serialVersionUID: Long = 1L - } } 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 aa01b1adeba6..c7af18a30457 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 @@ -50,8 +50,9 @@ import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView import org.wordpress.android.ui.newstats.components.StatsMapLegend import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.util.formatStatValue -import org.wordpress.android.util.extensions.getSerializableCompat -import java.io.Serializable +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" @@ -68,9 +69,8 @@ class CountriesDetailActivity : BaseAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - @Suppress("UNCHECKED_CAST") val countries = intent.extras - ?.getSerializableCompat>(EXTRA_COUNTRIES) + ?.getParcelableArrayListCompat(EXTRA_COUNTRIES) ?: arrayListOf() val mapData = intent.getStringExtra(EXTRA_MAP_DATA) ?: "" val minViews = intent.getLongExtra(EXTRA_MIN_VIEWS, 0L) @@ -137,17 +137,14 @@ class CountriesDetailActivity : BaseAppCompatActivity() { } } +@Parcelize data class CountriesDetailItem( val countryCode: String, val countryName: String, val views: Long, val flagIconUrl: String?, val change: CountryViewChange = CountryViewChange.NoChange -) : Serializable { - companion object { - private const val serialVersionUID: Long = 2L - } -} +) : Parcelable @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt index 714ede24f527..222bb7814848 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt @@ -1,7 +1,8 @@ package org.wordpress.android.ui.newstats.mostviewed +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import org.wordpress.android.R -import java.io.Serializable /** * Represents the available data source types for the Most Viewed card. @@ -47,28 +48,25 @@ data class MostViewedItem( /** * Represents the change in views compared to the previous period. */ -sealed class MostViewedChange : Serializable { +sealed class MostViewedChange : Parcelable { + @Parcelize data class Positive(val value: Long, val percentage: Double) : MostViewedChange() + @Parcelize data class Negative(val value: Long, val percentage: Double) : MostViewedChange() + @Parcelize data object NoChange : MostViewedChange() + @Parcelize data object NotAvailable : MostViewedChange() - - companion object { - private const val serialVersionUID: Long = 1L - } } /** * Data class for passing items to the detail screen via Intent. - * Implements Serializable for Intent extras. + * Implements Parcelable for efficient Intent extras. */ +@Parcelize data class MostViewedDetailItem( val id: Long, val title: String, val views: Long, val change: MostViewedChange -) : Serializable { - companion object { - private const val serialVersionUID: Long = 1L - } -} +) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt index 5e971206577c..b837903a4051 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt @@ -47,6 +47,7 @@ import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.StatsColors import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.util.formatStatValue +import org.wordpress.android.util.extensions.getParcelableArrayListCompat import org.wordpress.android.util.extensions.getSerializableCompat import java.util.Locale @@ -64,8 +65,7 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { val dataSource = intent.extras?.getSerializableCompat(EXTRA_DATA_SOURCE) ?: MostViewedDataSource.POSTS_AND_PAGES - @Suppress("UNCHECKED_CAST") - val items = intent.extras?.getSerializableCompat>(EXTRA_ITEMS) + val items = intent.extras?.getParcelableArrayListCompat(EXTRA_ITEMS) ?: arrayListOf() val totalViews = intent.getLongExtra(EXTRA_TOTAL_VIEWS, 0L) val totalViewsChange = intent.getLongExtra(EXTRA_TOTAL_VIEWS_CHANGE, 0L) From e52bf625989fa75decdb13e4828f222ed46bbdf6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 12:43:23 +0100 Subject: [PATCH 14/19] Extracting duplicated code --- .../newstats/countries/CountriesViewModel.kt | 16 +----- .../mostviewed/MostViewedViewModel.kt | 16 +----- .../ui/newstats/repository/StatsRepository.kt | 55 +++---------------- .../ui/newstats/util/StatsFormatter.kt | 20 +++++++ 4 files changed, 29 insertions(+), 78 deletions(-) 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 cf595583fb67..b2ba83bd7dd8 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 @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository @@ -15,12 +14,12 @@ import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.repository.CountryViewItemData import org.wordpress.android.ui.newstats.repository.CountryViewsResult import org.wordpress.android.ui.newstats.repository.StatsRepository +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 -private const val MONTH_ABBREVIATION_LENGTH = 3 @HiltViewModel class CountriesViewModel @Inject constructor( @@ -201,16 +200,3 @@ data class CountriesDetailData( val totalViewsChangePercent: Double, val dateRange: String ) - -private fun StatsPeriod.toDateRangeString(resourceProvider: ResourceProvider): String { - return when (this) { - is StatsPeriod.Today -> resourceProvider.getString(R.string.stats_period_today) - is StatsPeriod.Last7Days -> resourceProvider.getString(R.string.stats_period_last_7_days) - is StatsPeriod.Last30Days -> resourceProvider.getString(R.string.stats_period_last_30_days) - is StatsPeriod.Last6Months -> resourceProvider.getString(R.string.stats_period_last_6_months) - is StatsPeriod.Last12Months -> resourceProvider.getString(R.string.stats_period_last_12_months) - is StatsPeriod.Custom -> "${startDate.dayOfMonth}-${endDate.dayOfMonth} ${ - endDate.month.name.take(MONTH_ABBREVIATION_LENGTH).lowercase().replaceFirstChar { it.uppercase() } - }" - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt index fff38c8eb73a..b3244b6ee2fc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModel.kt @@ -14,12 +14,11 @@ import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.repository.MostViewedItemData import org.wordpress.android.ui.newstats.repository.MostViewedResult import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.util.toDateRangeString import kotlin.math.abs import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject -private const val MONTH_ABBREVIATION_LENGTH = 3 - @HiltViewModel class MostViewedViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, @@ -182,16 +181,3 @@ private fun MostViewedItemData.toMostViewedChange(): MostViewedChange { else -> MostViewedChange.NoChange } } - -private fun StatsPeriod.toDateRangeString(resourceProvider: ResourceProvider): String { - return when (this) { - is StatsPeriod.Today -> resourceProvider.getString(R.string.stats_period_today) - is StatsPeriod.Last7Days -> resourceProvider.getString(R.string.stats_period_last_7_days) - is StatsPeriod.Last30Days -> resourceProvider.getString(R.string.stats_period_last_30_days) - is StatsPeriod.Last6Months -> resourceProvider.getString(R.string.stats_period_last_6_months) - is StatsPeriod.Last12Months -> resourceProvider.getString(R.string.stats_period_last_12_months) - is StatsPeriod.Custom -> "${startDate.dayOfMonth}-${endDate.dayOfMonth} ${ - endDate.month.name.take(MONTH_ABBREVIATION_LENGTH).lowercase().replaceFirstChar { it.uppercase() } - }" - } -} 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 21635c32c70a..31d7c1b21fdf 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 @@ -505,7 +505,7 @@ class StatsRepository @Inject constructor( period: StatsPeriod, dataSource: MostViewedDataSource ): MostViewedResult = withContext(ioDispatcher) { - val (currentDateRange, previousDateRange) = calculateMostViewedDateRanges(period) + val (currentDateRange, previousDateRange) = calculateComparisonDateRanges(period) when (dataSource) { MostViewedDataSource.POSTS_AND_PAGES -> { @@ -623,7 +623,11 @@ class StatsRepository @Inject constructor( } } - private fun calculateMostViewedDateRanges(period: StatsPeriod): Pair { + /** + * Calculates current and previous date ranges for comparison stats. + * Used by multiple stats types (MostViewed, Countries, etc.) + */ + private fun calculateComparisonDateRanges(period: StatsPeriod): Pair { val today = LocalDate.now() val todayString = today.format(dateFormatter) @@ -679,7 +683,7 @@ class StatsRepository @Inject constructor( siteId: Long, period: StatsPeriod ): CountryViewsResult = withContext(ioDispatcher) { - val (currentDateRange, previousDateRange) = calculateCountryViewsDateRanges(period) + val (currentDateRange, previousDateRange) = calculateComparisonDateRanges(period) // Fetch both periods in parallel val (currentResult, previousResult) = coroutineScope { @@ -731,51 +735,6 @@ class StatsRepository @Inject constructor( } } } - - private fun calculateCountryViewsDateRanges(period: StatsPeriod): Pair { - val today = LocalDate.now() - val todayString = today.format(dateFormatter) - - return when (period) { - is StatsPeriod.Today -> { - val yesterdayString = today.minusDays(NUM_DAYS_TODAY.toLong()).format(dateFormatter) - StatsDateRange.Preset(num = NUM_DAYS_TODAY, date = todayString) to - StatsDateRange.Preset(num = NUM_DAYS_TODAY, date = yesterdayString) - } - is StatsPeriod.Last7Days -> { - val previousEndString = today.minusDays(DAYS_IN_7_DAYS.toLong()).format(dateFormatter) - StatsDateRange.Preset(num = DAYS_IN_7_DAYS, date = todayString) to - StatsDateRange.Preset(num = DAYS_IN_7_DAYS, date = previousEndString) - } - is StatsPeriod.Last30Days -> { - val previousEndString = today.minusDays(DAYS_IN_30_DAYS.toLong()).format(dateFormatter) - StatsDateRange.Preset(num = DAYS_IN_30_DAYS, date = todayString) to - StatsDateRange.Preset(num = DAYS_IN_30_DAYS, date = previousEndString) - } - is StatsPeriod.Last6Months -> { - val previousEndString = today.minusDays(DAYS_IN_6_MONTHS.toLong()).format(dateFormatter) - StatsDateRange.Preset(num = DAYS_IN_6_MONTHS, date = todayString) to - StatsDateRange.Preset(num = DAYS_IN_6_MONTHS, date = previousEndString) - } - is StatsPeriod.Last12Months -> { - val previousEndString = today.minusDays(DAYS_IN_12_MONTHS.toLong()).format(dateFormatter) - StatsDateRange.Preset(num = DAYS_IN_12_MONTHS, date = todayString) to - StatsDateRange.Preset(num = DAYS_IN_12_MONTHS, date = previousEndString) - } - is StatsPeriod.Custom -> { - val daysBetween = ChronoUnit.DAYS.between(period.startDate, period.endDate).toInt() + 1 - val previousEnd = period.startDate.minusDays(1) - val previousStart = previousEnd.minusDays(daysBetween.toLong() - 1) - StatsDateRange.Custom( - startDate = period.startDate.format(dateFormatter), - date = period.endDate.format(dateFormatter) - ) to StatsDateRange.Custom( - startDate = previousStart.format(dateFormatter), - date = previousEnd.format(dateFormatter) - ) - } - } - } } /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index d2b4b95e4730..85278c7e2d56 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -1,11 +1,15 @@ package org.wordpress.android.ui.newstats.util +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.viewmodel.ResourceProvider import java.util.Locale private const val THOUSAND = 1_000 private const val MILLION = 1_000_000 private const val FORMAT_MILLION = "%.1fM" private const val FORMAT_THOUSAND = "%.1fK" +private const val MONTH_ABBREVIATION_LENGTH = 3 /** * Formats a stat value for display, using K/M suffixes for large numbers. @@ -18,3 +22,19 @@ fun formatStatValue(value: Long): String { else -> value.toString() } } + +/** + * Converts a StatsPeriod to a human-readable date range string. + */ +fun StatsPeriod.toDateRangeString(resourceProvider: ResourceProvider): String { + return when (this) { + is StatsPeriod.Today -> resourceProvider.getString(R.string.stats_period_today) + is StatsPeriod.Last7Days -> resourceProvider.getString(R.string.stats_period_last_7_days) + is StatsPeriod.Last30Days -> resourceProvider.getString(R.string.stats_period_last_30_days) + is StatsPeriod.Last6Months -> resourceProvider.getString(R.string.stats_period_last_6_months) + is StatsPeriod.Last12Months -> resourceProvider.getString(R.string.stats_period_last_12_months) + is StatsPeriod.Custom -> "${startDate.dayOfMonth}-${endDate.dayOfMonth} ${ + endDate.month.name.take(MONTH_ABBREVIATION_LENGTH).lowercase().replaceFirstChar { it.uppercase() } + }" + } +} From ff0bc032589e30984ff9ccd81abb53275df34d32 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 13:00:50 +0100 Subject: [PATCH 15/19] Using common shimmer --- .../ui/newstats/countries/CountriesCard.kt | 61 +++---------------- 1 file changed, 10 insertions(+), 51 deletions(-) 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 018f86f7f437..dc5aed5b00fc 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,11 +1,5 @@ package org.wordpress.android.ui.newstats.countries -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -33,8 +27,6 @@ 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.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -46,12 +38,14 @@ import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView import org.wordpress.android.ui.newstats.components.StatsMapLegend import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 +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 @Composable fun CountriesCard( @@ -80,89 +74,54 @@ fun CountriesCard( @Composable private fun LoadingContent() { - val shimmerColors = listOf( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) - - val transition = rememberInfiniteTransition(label = "shimmer") - val translateAnimation = transition.animateFloat( - initialValue = 0f, - targetValue = 1000f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1200, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "shimmer_translate" - ) - - val shimmerBrush = Brush.linearGradient( - colors = shimmerColors, - start = Offset(translateAnimation.value - 500f, 0f), - end = Offset(translateAnimation.value, 0f) - ) - Column(modifier = Modifier.padding(CardPadding)) { // Title placeholder - Box( + ShimmerBox( modifier = Modifier .width(100.dp) .height(20.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerBrush) ) Spacer(modifier = Modifier.height(16.dp)) // Map placeholder - Box( + ShimmerBox( modifier = Modifier .fillMaxWidth() .aspectRatio(MAP_ASPECT_RATIO) .clip(RoundedCornerShape(8.dp)) - .background(shimmerBrush) ) Spacer(modifier = Modifier.height(16.dp)) // Legend placeholder - Box( + ShimmerBox( modifier = Modifier .width(150.dp) .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerBrush) ) Spacer(modifier = Modifier.height(16.dp)) // List items placeholders - repeat(4) { + repeat(LOADING_ITEM_COUNT) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerBrush) + ShimmerBox( + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(12.dp)) - Box( + ShimmerBox( modifier = Modifier .weight(1f) .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerBrush) ) Spacer(modifier = Modifier.width(12.dp)) - Box( + ShimmerBox( modifier = Modifier .width(50.dp) .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - .background(shimmerBrush) ) } } From 8e483242e111d994a811e342f385ec3bbfbaae24 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 13:12:15 +0100 Subject: [PATCH 16/19] Moving maps helpers inside map package --- .../wordpress/android/ui/newstats/countries/CountriesCard.kt | 3 --- .../android/ui/newstats/countries/CountriesDetailActivity.kt | 3 --- .../newstats/{components => countries}/StatsChangeIndicator.kt | 3 +-- .../newstats/{components => countries}/StatsGeoChartWebView.kt | 2 +- .../ui/newstats/{components => countries}/StatsMapLegend.kt | 2 +- 5 files changed, 3 insertions(+), 10 deletions(-) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{components => countries}/StatsChangeIndicator.kt (91%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{components => countries}/StatsGeoChartWebView.kt (99%) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/{components => countries}/StatsMapLegend.kt (98%) 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 dc5aed5b00fc..0b364d26fd11 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 @@ -33,9 +33,6 @@ 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.ui.newstats.components.StatsChangeIndicator -import org.wordpress.android.ui.newstats.components.StatsGeoChartWebView -import org.wordpress.android.ui.newstats.components.StatsMapLegend import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.newstats.util.ShimmerBox 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 c7af18a30457..2977e11067d0 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 @@ -45,9 +45,6 @@ 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.StatsGeoChartWebView -import org.wordpress.android.ui.newstats.components.StatsMapLegend import org.wordpress.android.ui.newstats.components.StatsSummaryCard import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.util.extensions.getParcelableArrayListCompat diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsChangeIndicator.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsChangeIndicator.kt similarity index 91% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsChangeIndicator.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsChangeIndicator.kt index 7ceac9bfeb52..dca3042ab0a3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsChangeIndicator.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsChangeIndicator.kt @@ -1,10 +1,9 @@ -package org.wordpress.android.ui.newstats.components +package org.wordpress.android.ui.newstats.countries import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import org.wordpress.android.ui.newstats.StatsColors -import org.wordpress.android.ui.newstats.countries.CountryViewChange import org.wordpress.android.ui.newstats.util.formatStatValue import java.util.Locale diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsGeoChartWebView.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsGeoChartWebView.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsGeoChartWebView.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsGeoChartWebView.kt index 78cffd4a07ea..c91316c4b4c4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsGeoChartWebView.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsGeoChartWebView.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.components +package org.wordpress.android.ui.newstats.countries import android.annotation.SuppressLint import android.graphics.Color diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsMapLegend.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsMapLegend.kt similarity index 98% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsMapLegend.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsMapLegend.kt index 1fc5b9f982a3..873954b5a0b8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsMapLegend.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsMapLegend.kt @@ -1,4 +1,4 @@ -package org.wordpress.android.ui.newstats.components +package org.wordpress.android.ui.newstats.countries import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box From 7461117319d683812570b6e9c173c2bc04647c03 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 13:59:23 +0100 Subject: [PATCH 17/19] Fixing lint --- .../android/ui/newstats/components/StatsSummaryCard.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt index 540b006a384f..b04b9b4db489 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsSummaryCard.kt @@ -44,9 +44,9 @@ import kotlin.math.abs fun StatsSummaryCard( totalViews: Long, dateRange: String, + modifier: Modifier = Modifier, totalViewsChange: Long? = null, - totalViewsChangePercent: Double? = null, - modifier: Modifier = Modifier + totalViewsChangePercent: Double? = null ) { Box( modifier = modifier From 3b4fecc8960a40fdef6511fd4993bc958c859e19 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 16:16:17 +0100 Subject: [PATCH 18/19] Adding some logs --- .../datasource/StatsDataSourceImpl.kt | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 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 86588ea2f64b..0bbf3d070fa1 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 @@ -61,15 +61,24 @@ class StatsDataSourceImpl @Inject constructor( ) } + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchStatsVisits result type: ${result::class.simpleName}") + return when (result) { is WpRequestResult.Success -> { + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchStatsVisits success") StatsVisitsDataResult.Success(mapToStatsVisitsData(result.response.data)) } is WpRequestResult.WpError -> { + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchStatsVisits WpError - ${result.errorMessage}") StatsVisitsDataResult.Error(result.errorMessage) } + is WpRequestResult.ResponseParsingError<*> -> { + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchStatsVisits ResponseParsingError - $result") + StatsVisitsDataResult.Error("Response parsing error: $result") + } else -> { - StatsVisitsDataResult.Error("Unknown error") + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchStatsVisits unexpected result - $result") + StatsVisitsDataResult.Error("Unknown error: ${result::class.simpleName}") } } } @@ -201,9 +210,12 @@ class StatsDataSourceImpl @Inject constructor( ) } + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchReferrers result type: ${result::class.simpleName}") + return when (result) { is WpRequestResult.Success -> { val groups = result.response.data.summary?.groups.orEmpty() + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchReferrers success - ${groups.size} groups") ReferrersDataResult.Success( groups.map { group -> ReferrerDataItem( @@ -214,10 +226,16 @@ class StatsDataSourceImpl @Inject constructor( ) } is WpRequestResult.WpError -> { + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchReferrers WpError - ${result.errorMessage}") ReferrersDataResult.Error(result.errorMessage) } + is WpRequestResult.ResponseParsingError<*> -> { + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchReferrers ResponseParsingError - $result") + ReferrersDataResult.Error("Response parsing error: $result") + } else -> { - ReferrersDataResult.Error("Unknown error") + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchReferrers unexpected result - $result") + ReferrersDataResult.Error("Unknown error: ${result::class.simpleName}") } } } @@ -253,6 +271,8 @@ class StatsDataSourceImpl @Inject constructor( ) } + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchCountryViews result type: ${result::class.simpleName}") + return when (result) { is WpRequestResult.Success -> { val summary = result.response.data.summary @@ -269,6 +289,7 @@ class StatsDataSourceImpl @Inject constructor( ) } + AppLog.d(T.STATS, "StatsDataSourceImpl: fetchCountryViews success - ${countries.size} countries") CountryViewsDataResult.Success( CountryViewsData( countries = countries, @@ -278,10 +299,16 @@ class StatsDataSourceImpl @Inject constructor( ) } is WpRequestResult.WpError -> { + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchCountryViews WpError - ${result.errorMessage}") CountryViewsDataResult.Error(result.errorMessage) } + is WpRequestResult.ResponseParsingError<*> -> { + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchCountryViews ResponseParsingError - $result") + CountryViewsDataResult.Error("Response parsing error: $result") + } else -> { - CountryViewsDataResult.Error("Unknown error") + AppLog.e(T.STATS, "StatsDataSourceImpl: fetchCountryViews unexpected result - $result") + CountryViewsDataResult.Error("Unknown error: ${result::class.simpleName}") } } } From 2872ca572da11084866379cd52f859fa678d88aa Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 30 Jan 2026 16:27:22 +0100 Subject: [PATCH 19/19] Updating library and handling the empty result --- .../ui/newstats/countries/CountriesCard.kt | 2 +- .../datasource/StatsDataSourceImpl.kt | 39 ++++++++++--------- gradle/libs.versions.toml | 2 +- 3 files changed, 22 insertions(+), 21 deletions(-) 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 0b364d26fd11..f8428268f49f 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 @@ -201,7 +201,7 @@ private fun EmptyContent() { contentAlignment = Alignment.Center ) { Text( - text = stringResource(R.string.stats_no_data_for_period), + text = stringResource(R.string.stats_no_data_yet), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) 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 0bbf3d070fa1..9fda0e911781 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 @@ -240,30 +240,31 @@ class StatsDataSourceImpl @Inject constructor( } } + private fun buildCountryViewsParams(dateRange: StatsDateRange, max: Int) = when (dateRange) { + is StatsDateRange.Preset -> StatsCountryViewsParams( + period = StatsCountryViewsPeriod.DAY, + date = dateRange.date, + num = dateRange.num.toUInt(), + max = max.coerceAtLeast(1).toUInt(), + locale = localeManagerWrapper.getLocale().toString(), + summarize = true + ) + is StatsDateRange.Custom -> StatsCountryViewsParams( + period = StatsCountryViewsPeriod.DAY, + date = dateRange.date, + startDate = dateRange.startDate, + max = max.coerceAtLeast(1).toUInt(), + locale = localeManagerWrapper.getLocale().toString(), + summarize = true + ) + } + override suspend fun fetchCountryViews( siteId: Long, dateRange: StatsDateRange, max: Int ): CountryViewsDataResult { - val params = when (dateRange) { - is StatsDateRange.Preset -> StatsCountryViewsParams( - period = StatsCountryViewsPeriod.DAY, - date = dateRange.date, - num = dateRange.num.toUInt(), - max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString(), - summarize = true - ) - is StatsDateRange.Custom -> StatsCountryViewsParams( - period = StatsCountryViewsPeriod.DAY, - date = dateRange.date, - startDate = dateRange.startDate, - max = max.coerceAtLeast(1).toUInt(), - locale = localeManagerWrapper.getLocale().toString(), - summarize = true - ) - } - + val params = buildCountryViewsParams(dateRange, max) val result = wpComApiClient.request { requestBuilder -> requestBuilder.statsCountryViews().getStatsCountryViews( wpComSiteId = siteId.toULong(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a34ac2fd5656..f9841ea1108f 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-4bf0d13973795483d4c3e489d8605f51e3a58efe' +wordpress-rs = '1134-6cdddac9b99642742ae89bb5833bbe2ed1c7cdd1' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2'