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" />
+
+
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
new file mode 100644
index 000000000000..f8428268f49f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCard.kt
@@ -0,0 +1,397 @@
+package org.wordpress.android.ui.newstats.countries
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import org.wordpress.android.R
+import org.wordpress.android.ui.compose.theme.AppThemeM3
+import org.wordpress.android.ui.newstats.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(
+ uiState: CountriesCardUiState,
+ onShowAllClick: () -> Unit,
+ 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, onShowAllClick)
+ is CountriesCardUiState.Error -> ErrorContent(uiState, onRetry)
+ }
+ }
+}
+
+@Composable
+private fun LoadingContent() {
+ Column(modifier = Modifier.padding(CardPadding)) {
+ // Title placeholder
+ ShimmerBox(
+ modifier = Modifier
+ .width(100.dp)
+ .height(20.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Map placeholder
+ ShimmerBox(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(MAP_ASPECT_RATIO)
+ .clip(RoundedCornerShape(8.dp))
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Legend placeholder
+ ShimmerBox(
+ modifier = Modifier
+ .width(150.dp)
+ .height(16.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // List items placeholders
+ repeat(LOADING_ITEM_COUNT) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ShimmerBox(
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ ShimmerBox(
+ modifier = Modifier
+ .weight(1f)
+ .height(16.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ ShimmerBox(
+ modifier = Modifier
+ .width(50.dp)
+ .height(16.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadedContent(state: CountriesCardUiState.Loaded, onShowAllClick: () -> Unit) {
+ 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
+ StatsMapLegend(
+ 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 (capped at 10 items)
+ state.countries.forEachIndexed { index, country ->
+ 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))
+ }
+ }
+
+ // Show All footer
+ Spacer(modifier = Modifier.height(12.dp))
+ ShowAllFooter(onClick = onShowAllClick)
+ }
+ }
+}
+
+@Composable
+private fun EmptyContent() {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = stringResource(R.string.stats_no_data_yet),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun CountryMap(
+ mapData: String,
+ modifier: Modifier = Modifier
+) {
+ StatsGeoChartWebView(
+ mapData = mapData,
+ modifier = modifier
+ )
+}
+
+@Composable
+private fun CountryRow(
+ country: CountryItem,
+ percentage: Float
+) {
+ val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min)
+ .clip(RoundedCornerShape(8.dp))
+ ) {
+ // Background bar representing the percentage
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(fraction = percentage)
+ .fillMaxHeight()
+ .background(barColor)
+ )
+
+ // Content
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Flag icon
+ if (country.flagIconUrl != null) {
+ AsyncImage(
+ model = country.flagIconUrl,
+ contentDescription = country.countryName,
+ modifier = Modifier.size(24.dp)
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant,
+ RoundedCornerShape(4.dp)
+ )
+ )
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Country name
+ Text(
+ text = country.countryName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Views count and change
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = formatStatValue(country.views),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ StatsChangeIndicator(change = country.change)
+ }
+ }
+ }
+}
+
+@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,
+ 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))
+ }
+}
+
+// Previews
+@Preview(showBackground = true)
+@Composable
+private fun CountriesCardLoadingPreview() {
+ AppThemeM3 {
+ CountriesCard(
+ uiState = CountriesCardUiState.Loading,
+ onShowAllClick = {},
+ onRetry = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CountriesCardLoadedPreview() {
+ AppThemeM3 {
+ CountriesCard(
+ uiState = CountriesCardUiState.Loaded(
+ countries = listOf(
+ 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,
+ maxViews = 3464,
+ maxViewsForBar = 3464,
+ hasMoreItems = true
+ ),
+ onShowAllClick = {},
+ onRetry = {}
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+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
new file mode 100644
index 000000000000..536f90d5b6f5
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesCardUiState.kt
@@ -0,0 +1,51 @@
+package org.wordpress.android.ui.newstats.countries
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * 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,
+ val maxViewsForBar: Long,
+ val hasMoreItems: Boolean
+ ) : 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
+ * @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 change: CountryViewChange = CountryViewChange.NoChange
+)
+
+/**
+ * Represents the change in views for a country compared to the previous period.
+ */
+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()
+}
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..2977e11067d0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesDetailActivity.kt
@@ -0,0 +1,368 @@
+package org.wordpress.android.ui.newstats.countries
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import dagger.hilt.android.AndroidEntryPoint
+import 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.StatsSummaryCard
+import org.wordpress.android.ui.newstats.util.formatStatValue
+import org.wordpress.android.util.extensions.getParcelableArrayListCompat
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+private const val EXTRA_COUNTRIES = "extra_countries"
+private const val EXTRA_MAP_DATA = "extra_map_data"
+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
+class CountriesDetailActivity : BaseAppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val countries = intent.extras
+ ?.getParcelableArrayListCompat(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)
+ val totalViews = intent.getLongExtra(EXTRA_TOTAL_VIEWS, 0L)
+ val totalViewsChange = intent.getLongExtra(EXTRA_TOTAL_VIEWS_CHANGE, 0L)
+ val totalViewsChangePercent = intent.getDoubleExtra(EXTRA_TOTAL_VIEWS_CHANGE_PERCENT, 0.0)
+ val dateRange = intent.getStringExtra(EXTRA_DATE_RANGE) ?: ""
+ // Calculate maxViewsForBar once (list is sorted by views descending)
+ val maxViewsForBar = countries.firstOrNull()?.views ?: 1L
+
+ setContent {
+ AppThemeM3 {
+ CountriesDetailScreen(
+ countries = countries,
+ mapData = mapData,
+ minViews = minViews,
+ maxViews = maxViews,
+ maxViewsForBar = maxViewsForBar,
+ totalViews = totalViews,
+ totalViewsChange = totalViewsChange,
+ totalViewsChangePercent = totalViewsChangePercent,
+ dateRange = dateRange,
+ onBackPressed = onBackPressedDispatcher::onBackPressed
+ )
+ }
+ }
+ }
+
+ companion object {
+ @Suppress("LongParameterList")
+ fun start(
+ context: Context,
+ countries: List,
+ mapData: String,
+ minViews: 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,
+ change = country.change
+ )
+ }
+ 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)
+ 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)
+ }
+ }
+}
+
+@Parcelize
+data class CountriesDetailItem(
+ val countryCode: String,
+ val countryName: String,
+ val views: Long,
+ val flagIconUrl: String?,
+ val change: CountryViewChange = CountryViewChange.NoChange
+) : Parcelable
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CountriesDetailScreen(
+ countries: List,
+ mapData: String,
+ minViews: Long,
+ maxViews: Long,
+ maxViewsForBar: Long,
+ totalViews: Long,
+ totalViewsChange: Long,
+ totalViewsChangePercent: Double,
+ dateRange: String,
+ 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))
+ // Summary card
+ StatsSummaryCard(
+ totalViews = totalViews,
+ dateRange = dateRange,
+ totalViewsChange = totalViewsChange,
+ totalViewsChangePercent = totalViewsChangePercent
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Map
+ CountryMap(
+ mapData = mapData,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(MAP_ASPECT_RATIO)
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Legend
+ StatsMapLegend(minViews = minViews, maxViews = maxViews)
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+
+ item {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(R.string.stats_countries_location_header),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringResource(R.string.stats_countries_views_header),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ itemsIndexed(countries) { index, country ->
+ val percentage = if (maxViewsForBar > 0) {
+ country.views.toFloat() / maxViewsForBar.toFloat()
+ } else 0f
+ DetailCountryRow(
+ position = index + 1,
+ country = country,
+ percentage = percentage
+ )
+ if (index < countries.lastIndex) {
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun CountryMap(
+ mapData: String,
+ modifier: Modifier = Modifier
+) {
+ StatsGeoChartWebView(
+ mapData = mapData,
+ modifier = modifier
+ )
+}
+
+@Composable
+private fun DetailCountryRow(
+ position: Int,
+ country: CountriesDetailItem,
+ percentage: Float
+) {
+ val barColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min)
+ .clip(RoundedCornerShape(8.dp))
+ ) {
+ // Background bar representing the percentage
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(fraction = percentage)
+ .fillMaxHeight()
+ .background(barColor)
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp, horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Position number
+ Text(
+ text = position.toString(),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.width(32.dp)
+ )
+
+ // Flag icon
+ if (country.flagIconUrl != null) {
+ AsyncImage(
+ model = country.flagIconUrl,
+ contentDescription = country.countryName,
+ modifier = Modifier.size(24.dp)
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant,
+ RoundedCornerShape(4.dp)
+ )
+ )
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Country name
+ Text(
+ text = country.countryName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Views count and change
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ text = formatStatValue(country.views),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ StatsChangeIndicator(change = country.change)
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CountriesDetailScreenPreview() {
+ AppThemeM3 {
+ CountriesDetailScreen(
+ countries = listOf(
+ 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,
+ maxViewsForBar = 3464,
+ totalViews = 6726,
+ totalViewsChange = 225,
+ totalViewsChangePercent = 3.5,
+ dateRange = "Last 7 days",
+ onBackPressed = {}
+ )
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt
new file mode 100644
index 000000000000..b2ba83bd7dd8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/CountriesViewModel.kt
@@ -0,0 +1,202 @@
+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.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
+
+@HiltViewModel
+class CountriesViewModel @Inject constructor(
+ private val selectedSiteRepository: SelectedSiteRepository,
+ private val accountStore: AccountStore,
+ private val statsRepository: StatsRepository,
+ private val resourceProvider: ResourceProvider
+) : 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
+
+ private var allCountries: List = emptyList()
+ 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()
+ }
+
+ 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()
+ }
+ }
+
+ fun getDetailData(): CountriesDetailData {
+ return CountriesDetailData(
+ countries = allCountries,
+ mapData = cachedMapData,
+ minViews = cachedMinViews,
+ maxViews = cachedMaxViews,
+ totalViews = cachedTotalViews,
+ totalViewsChange = cachedTotalViewsChange,
+ totalViewsChangePercent = cachedTotalViewsChangePercent,
+ dateRange = currentPeriod.toDateRangeString(resourceProvider)
+ )
+ }
+
+ 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 -> {
+ cachedTotalViews = result.totalViews
+ cachedTotalViewsChange = result.totalViewsChange
+ cachedTotalViewsChangePercent = result.totalViewsChangePercent
+
+ if (result.countries.isEmpty()) {
+ allCountries = emptyList()
+ cachedMapData = ""
+ cachedMinViews = 0L
+ cachedMaxViews = 0L
+ _uiState.value = CountriesCardUiState.Loaded(
+ countries = emptyList(),
+ mapData = "",
+ minViews = 0,
+ maxViews = 0,
+ maxViewsForBar = 0,
+ hasMoreItems = false
+ )
+ } else {
+ val countries = result.countries.map { country ->
+ CountryItem(
+ countryCode = country.countryCode,
+ countryName = country.countryName,
+ views = country.views,
+ flagIconUrl = country.flagIconUrl,
+ change = country.toCountryViewChange()
+ )
+ }
+
+ // Build map data for Google GeoChart
+ val mapData = buildMapData(countries)
+ 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
+
+ // 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 = cardCountries,
+ mapData = mapData,
+ minViews = cachedMinViews,
+ maxViews = cachedMaxViews,
+ maxViewsForBar = maxViewsForBar,
+ hasMoreItems = countries.size > CARD_MAX_ITEMS
+ )
+ }
+ }
+ is CountryViewsResult.Error -> {
+ _uiState.value = CountriesCardUiState.Error(result.message)
+ }
+ }
+ }
+
+ 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],...
+ */
+ private fun buildMapData(countries: List): String {
+ return countries.joinToString(",") { country ->
+ "['${country.countryCode}',${country.views}]"
+ }
+ }
+}
+
+data class CountriesDetailData(
+ val countries: List,
+ val mapData: String,
+ val minViews: Long,
+ val maxViews: Long,
+ val totalViews: Long,
+ val totalViewsChange: Long,
+ val totalViewsChangePercent: Double,
+ val dateRange: String
+)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsChangeIndicator.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsChangeIndicator.kt
new file mode 100644
index 000000000000..dca3042ab0a3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsChangeIndicator.kt
@@ -0,0 +1,40 @@
+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.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/countries/StatsGeoChartWebView.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsGeoChartWebView.kt
new file mode 100644
index 000000000000..c91316c4b4c4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsGeoChartWebView.kt
@@ -0,0 +1,163 @@
+package org.wordpress.android.ui.newstats.countries
+
+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/StatsMapLegend.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsMapLegend.kt
new file mode 100644
index 000000000000..873954b5a0b8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/countries/StatsMapLegend.kt
@@ -0,0 +1,72 @@
+package org.wordpress.android.ui.newstats.countries
+
+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/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/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt
index fdc752e6c59a..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
@@ -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
@@ -59,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}")
}
}
}
@@ -199,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(
@@ -212,10 +226,90 @@ 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 -> {
+ AppLog.e(T.STATS, "StatsDataSourceImpl: fetchReferrers unexpected result - $result")
+ ReferrersDataResult.Error("Unknown error: ${result::class.simpleName}")
+ }
+ }
+ }
+
+ 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 = buildCountryViewsParams(dateRange, max)
+ val result = wpComApiClient.request { requestBuilder ->
+ requestBuilder.statsCountryViews().getStatsCountryViews(
+ wpComSiteId = siteId.toULong(),
+ params = params
+ )
+ }
+
+ AppLog.d(T.STATS, "StatsDataSourceImpl: fetchCountryViews result type: ${result::class.simpleName}")
+
+ 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
+ )
+ }
+
+ AppLog.d(T.STATS, "StatsDataSourceImpl: fetchCountryViews success - ${countries.size} countries")
+ CountryViewsDataResult.Success(
+ CountryViewsData(
+ countries = countries,
+ totalViews = summary?.totalViews?.toLong() ?: 0L,
+ otherViews = summary?.otherViews?.toLong() ?: 0L
+ )
+ )
+ }
+ 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 -> {
- ReferrersDataResult.Error("Unknown error")
+ AppLog.e(T.STATS, "StatsDataSourceImpl: fetchCountryViews unexpected result - $result")
+ CountryViewsDataResult.Error("Unknown error: ${result::class.simpleName}")
}
}
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt
index 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..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.
@@ -20,7 +21,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()
@@ -46,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 27130eee68e3..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
@@ -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,11 @@ 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.getParcelableArrayListCompat
+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"
@@ -66,19 +65,21 @@ 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)
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,
@@ -118,6 +119,7 @@ class MostViewedDetailActivity : BaseAppCompatActivity() {
private fun MostViewedDetailScreen(
dataSource: MostViewedDataSource,
items: List,
+ maxViewsForBar: Long,
totalViews: Long,
totalViewsChange: Long,
totalViewsChangePercent: Double,
@@ -149,11 +151,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))
}
@@ -167,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))
@@ -181,81 +183,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(
@@ -279,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(
@@ -389,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..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,
@@ -129,9 +128,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 +142,8 @@ class MostViewedViewModel @Inject constructor(
change = item.change,
isHighlighted = index == 0
)
- }
+ },
+ maxViewsForBar = maxViewsForBar
)
}
is MostViewedResult.Error -> {
@@ -177,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 7b3452731e22..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
@@ -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
@@ -504,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 -> {
@@ -622,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)
@@ -666,6 +671,70 @@ class StatsRepository @Inject constructor(
}
}
}
+
+ /**
+ * 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 with comparison or error
+ */
+ suspend fun fetchCountryViews(
+ siteId: Long,
+ period: StatsPeriod
+ ): CountryViewsResult = withContext(ioDispatcher) {
+ val (currentDateRange, previousDateRange) = calculateComparisonDateRanges(period)
+
+ // 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 (currentResult) {
+ is CountryViewsDataResult.Success -> {
+ val previousCountriesMap = if (previousResult is CountryViewsDataResult.Success) {
+ previousResult.data.countries.associateBy { it.countryCode }
+ } else {
+ emptyMap()
+ }
+
+ // 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.countries.sumOf { it.views }
+ } else {
+ 0L
+ }
+ val totalChange = totalViews - previousTotalViews
+ val totalChangePercent = if (previousTotalViews > 0) {
+ (totalChange.toDouble() / previousTotalViews.toDouble()) * PERCENTAGE_MULTIPLIER
+ } else if (totalViews > 0) PERCENTAGE_MULTIPLIER else PERCENTAGE_NO_CHANGE
+
+ CountryViewsResult.Success(
+ 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,
+ previousViews = previousViews
+ )
+ },
+ totalViews = totalViews,
+ otherViews = currentResult.data.otherViews,
+ totalViewsChange = totalChange,
+ totalViewsChangePercent = totalChangePercent
+ )
+ }
+ is CountryViewsDataResult.Error -> {
+ appLogWrapper.e(AppLog.T.STATS, "Error fetching country views: ${currentResult.message}")
+ CountryViewsResult.Error(currentResult.message)
+ }
+ }
+ }
}
/**
@@ -797,3 +866,37 @@ 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,
+ val totalViewsChange: Long,
+ val totalViewsChangePercent: Double
+ ) : 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?,
+ val previousViews: Long
+) {
+ val viewsChange: Long get() = views - previousViews
+ val viewsChangePercent: Double get() = if (previousViews > 0) {
+ (viewsChange.toDouble() / previousViews.toDouble()) * PERCENTAGE_MULTIPLIER
+ } else if (views > 0) {
+ PERCENTAGE_MULTIPLIER
+ } else {
+ PERCENTAGE_NO_CHANGE
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/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() }
+ }"
+ }
+}
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/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
+ }
+}
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
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4e2be41c5011..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 = '1119-c0b6b2248a27094599f6411ee52e68e7baafc469'
+wordpress-rs = '1134-6cdddac9b99642742ae89bb5833bbe2ed1c7cdd1'
wordpress-utils = '3.14.0'
automattic-ucrop = '2.2.11'
zendesk = '5.5.2'