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'