From 8fb79cb443d60e0e74aa9917a30cada55bb5fcd6 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 16 Jan 2026 16:44:01 +0100 Subject: [PATCH 01/22] Adding card and basic info --- WordPress/build.gradle | 3 + .../android/ui/newstats/NewStatsActivity.kt | 32 ++ .../ui/newstats/todaysstat/TodaysStatsCard.kt | 396 ++++++++++++++++++ .../todaysstat/TodaysStatsCardUiState.kt | 41 ++ .../todaysstat/TodaysStatsViewModel.kt | 161 +++++++ gradle/libs.versions.toml | 2 + 6 files changed, 635 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt diff --git a/WordPress/build.gradle b/WordPress/build.gradle index faca76dc50d1..cd4cb1c3e5eb 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -573,6 +573,9 @@ dependencies { // Cascade - Compose nested menu implementation(libs.cascade.compose) + // Vico - Charts for Compose + implementation(libs.vico.compose.m3) + implementation(libs.automattic.encryptedlogging) implementation(libs.automattic.tracks.crashlogging) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index d90eac5eb6a6..7730af1921c5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api @@ -21,16 +23,22 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.ui.newstats.todaysstat.TodaysStatsCard +import org.wordpress.android.ui.newstats.todaysstat.TodaysStatsViewModel @AndroidEntryPoint class NewStatsActivity : BaseAppCompatActivity() { @@ -115,6 +123,30 @@ private fun NewStatsScreen( @Composable private fun StatsTabContent(tab: StatsTab) { + when (tab) { + StatsTab.TRAFFIC -> TrafficTabContent() + else -> PlaceholderTabContent(tab) + } +} + +@Composable +private fun TrafficTabContent( + viewModel: TodaysStatsViewModel = viewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + TodaysStatsCard(uiState = uiState) + } +} + +@Composable +private fun PlaceholderTabContent(tab: StatsTab) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt new file mode 100644 index 000000000000..6337bd8de892 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -0,0 +1,396 @@ +package org.wordpress.android.ui.newstats.todaysstat + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChatBubbleOutline +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.common.fill +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.lineSeries +import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val CardCornerRadius = 16.dp +private val CardPadding = 16.dp +private val ChartHeight = 80.dp +private val MetricIconSize = 16.dp +private val MetricSpacing = 4.dp + +@Composable +fun TodaysStatsCard( + uiState: TodaysStatsCardUiState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CardCornerRadius)), + shape = RoundedCornerShape(CardCornerRadius), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + when (uiState) { + is TodaysStatsCardUiState.Loading -> LoadingContent() + is TodaysStatsCardUiState.Loaded -> LoadedContent(uiState) + is TodaysStatsCardUiState.Error -> ErrorContent(uiState) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + CardHeader() + Spacer(modifier = Modifier.height(12.dp)) + // Placeholder for chart + Box( + modifier = Modifier + .fillMaxWidth() + .height(ChartHeight) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) + Spacer(modifier = Modifier.height(16.dp)) + // Placeholder for metrics + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + repeat(4) { + Box( + modifier = Modifier + .width(60.dp) + .height(40.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) + } + } + } +} + +@Composable +private fun LoadedContent(state: TodaysStatsCardUiState.Loaded) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = state.onCardClick) + .padding(CardPadding) + ) { + CardHeader() + Spacer(modifier = Modifier.height(12.dp)) + StatsChart(chartData = state.chartData) + Spacer(modifier = Modifier.height(16.dp)) + MetricsRow( + views = state.views, + visitors = state.visitors, + likes = state.likes, + comments = state.comments + ) + } +} + +@Composable +private fun ErrorContent(state: TodaysStatsCardUiState.Error) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CardHeader() + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = state.onRetry) { + Text(text = stringResource(R.string.retry)) + } + } +} + +@Composable +private fun CardHeader() { + val dateFormat = remember { SimpleDateFormat("EEEE, MMM d", Locale.getDefault()) } + val formattedDate = remember { dateFormat.format(Date()) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.stats_insights_today), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = formattedDate, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun StatsChart(chartData: ChartData) { + val modelProducer = remember { CartesianChartModelProducer() } + + LaunchedEffect(chartData) { + if (chartData.currentPeriod.isNotEmpty()) { + modelProducer.runTransaction { + lineSeries { + series(chartData.currentPeriod.map { it.views.toInt() }) + } + if (chartData.previousPeriod.isNotEmpty()) { + lineSeries { + series(chartData.previousPeriod.map { it.views.toInt() }) + } + } + } + } + } + + if (chartData.currentPeriod.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(ChartHeight) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.stats_no_data_yet), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + + val primaryColor = MaterialTheme.colorScheme.primary + val secondaryColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + + CartesianChartHost( + chart = rememberCartesianChart( + rememberLineCartesianLayer( + lineProvider = LineCartesianLayer.LineProvider.series( + LineCartesianLayer.Line( + fill = LineCartesianLayer.LineFill.single(fill(primaryColor)), + areaFill = LineCartesianLayer.AreaFill.single( + fill(primaryColor.copy(alpha = 0.2f)) + ) + ), + LineCartesianLayer.Line( + fill = LineCartesianLayer.LineFill.single(fill(secondaryColor)) + ) + ) + ) + ), + modelProducer = modelProducer, + modifier = Modifier + .fillMaxWidth() + .height(ChartHeight) + ) +} + +@Composable +private fun MetricsRow( + views: Int, + visitors: Int, + likes: Int, + comments: Int +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + MetricItem( + icon = Icons.Default.Visibility, + value = formatStatValue(views), + label = stringResource(R.string.stats_views), + isPrimary = true + ) + MetricItem( + icon = Icons.Default.Person, + value = formatStatValue(visitors), + label = stringResource(R.string.stats_visitors) + ) + MetricItem( + icon = Icons.Default.FavoriteBorder, + value = formatStatValue(likes), + label = stringResource(R.string.stats_likes) + ) + MetricItem( + icon = Icons.Default.ChatBubbleOutline, + value = formatStatValue(comments), + label = stringResource(R.string.stats_comments) + ) + } +} + +@Composable +private fun MetricItem( + icon: ImageVector, + value: String, + label: String, + isPrimary: Boolean = false +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MetricSpacing) + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(MetricIconSize), + tint = if (isPrimary) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + Text( + text = value, + style = if (isPrimary) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.titleMedium + }, + fontWeight = FontWeight.Bold, + color = if (isPrimary) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +private fun formatStatValue(value: Int): String { + return when { + value >= 1_000_000 -> String.format(Locale.getDefault(), "%.1fM", value / 1_000_000.0) + value >= 1_000 -> String.format(Locale.getDefault(), "%.1fK", value / 1_000.0) + else -> value.toString() + } +} + +@Preview(showBackground = true) +@Composable +private fun TodaysStatsCardLoadingPreview() { + AppThemeM3 { + TodaysStatsCard( + uiState = TodaysStatsCardUiState.Loading, + modifier = Modifier.padding(16.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TodaysStatsCardLoadedPreview() { + AppThemeM3 { + TodaysStatsCard( + uiState = TodaysStatsCardUiState.Loaded( + views = 1234, + visitors = 567, + likes = 89, + comments = 12, + chartData = ChartData( + currentPeriod = listOf( + ViewsDataPoint("Mon", 100), + ViewsDataPoint("Tue", 150), + ViewsDataPoint("Wed", 120), + ViewsDataPoint("Thu", 200), + ViewsDataPoint("Fri", 180), + ViewsDataPoint("Sat", 250), + ViewsDataPoint("Sun", 220) + ), + previousPeriod = listOf( + ViewsDataPoint("Mon", 80), + ViewsDataPoint("Tue", 120), + ViewsDataPoint("Wed", 100), + ViewsDataPoint("Thu", 160), + ViewsDataPoint("Fri", 140), + ViewsDataPoint("Sat", 200), + ViewsDataPoint("Sun", 180) + ) + ), + onCardClick = {} + ), + modifier = Modifier.padding(16.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun TodaysStatsCardErrorPreview() { + AppThemeM3 { + TodaysStatsCard( + uiState = TodaysStatsCardUiState.Error( + message = "Failed to load stats", + onRetry = {} + ), + modifier = Modifier.padding(16.dp) + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt new file mode 100644 index 000000000000..2c138efd4044 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt @@ -0,0 +1,41 @@ +package org.wordpress.android.ui.newstats.todaysstat + +/** + * UI State for the Today's Stats card in the new stats screen. + */ +sealed class TodaysStatsCardUiState { + data object Loading : TodaysStatsCardUiState() + + data class Loaded( + val views: Int, + val visitors: Int, + val likes: Int, + val comments: Int, + val chartData: ChartData, + val onCardClick: () -> Unit + ) : TodaysStatsCardUiState() + + data class Error( + val message: String, + val onRetry: () -> Unit + ) : TodaysStatsCardUiState() +} + +/** + * Data for the sparkline chart showing views over time. + * Contains both current period and previous period for comparison. + */ +data class ChartData( + val currentPeriod: List, + val previousPeriod: List +) + +/** + * A single data point for the chart. + * @param label The label for this point (e.g., day name or date) + * @param views The number of views for this period + */ +data class ViewsDataPoint( + val label: String, + val views: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt new file mode 100644 index 000000000000..87f9c513fee2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -0,0 +1,161 @@ +package org.wordpress.android.ui.newstats.todaysstat + +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.model.stats.LimitMode +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore +import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject + +private const val CHART_DATA_POINTS = 7 +private const val PREVIOUS_PERIOD_OFFSET_DAYS = 7 + +@HiltViewModel +class TodaysStatsViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val todayInsightsStore: TodayInsightsStore, + private val visitsAndViewsStore: VisitsAndViewsStore +) : ViewModel() { + private val _uiState = MutableStateFlow(TodaysStatsCardUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + } + + fun loadData(forced: Boolean = false) { + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + _uiState.value = TodaysStatsCardUiState.Error( + message = "No site selected", + onRetry = { loadData(forced = true) } + ) + return + } + + _uiState.value = TodaysStatsCardUiState.Loading + + viewModelScope.launch { + try { + val todayStats = fetchTodayStats(site, forced) + val chartData = fetchChartData(site, forced) + + if (todayStats != null) { + _uiState.value = TodaysStatsCardUiState.Loaded( + views = todayStats.views, + visitors = todayStats.visitors, + likes = todayStats.likes, + comments = todayStats.comments, + chartData = chartData, + onCardClick = { onCardClicked() } + ) + } else { + _uiState.value = TodaysStatsCardUiState.Error( + message = "Failed to load stats", + onRetry = { loadData(forced = true) } + ) + } + } catch (e: Exception) { + _uiState.value = TodaysStatsCardUiState.Error( + message = e.message ?: "Unknown error", + onRetry = { loadData(forced = true) } + ) + } + } + } + + private suspend fun fetchTodayStats(site: SiteModel, forced: Boolean): TodayStatsData? { + val response = todayInsightsStore.fetchTodayInsights(site, forced) + return if (response.isError) { + null + } else { + response.model?.let { model -> + TodayStatsData( + views = model.views, + visitors = model.visitors, + likes = model.likes, + comments = model.comments + ) + } + } + } + + private suspend fun fetchChartData(site: SiteModel, forced: Boolean): ChartData { + val currentPeriodData = fetchPeriodData(site, forced, offsetDays = 0) + val previousPeriodData = fetchPeriodData(site, forced, offsetDays = PREVIOUS_PERIOD_OFFSET_DAYS) + + return ChartData( + currentPeriod = currentPeriodData, + previousPeriod = previousPeriodData + ) + } + + private suspend fun fetchPeriodData( + site: SiteModel, + forced: Boolean, + offsetDays: Int + ): List { + val calendar = Calendar.getInstance() + if (offsetDays > 0) { + calendar.add(Calendar.DAY_OF_YEAR, -offsetDays) + } + + val response = visitsAndViewsStore.fetchVisits( + site = site, + granularity = StatsGranularity.DAYS, + limitMode = LimitMode.Top(CHART_DATA_POINTS), + date = calendar.time, + forced = forced + ) + + val model = response.model + if (response.isError || model == null) { + return emptyList() + } + + val dateFormat = SimpleDateFormat("EEE", Locale.getDefault()) + + return model.dates.map { periodData -> + ViewsDataPoint( + label = formatPeriodLabel(periodData.period, dateFormat), + views = periodData.views + ) + } + } + + private fun formatPeriodLabel(period: String, dateFormat: SimpleDateFormat): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val date = inputFormat.parse(period) + date?.let { dateFormat.format(it) } ?: period + } catch (e: Exception) { + period + } + } + + private fun onCardClicked() { + // Navigation will be handled by the parent + } + + fun onRetry() { + loadData(forced = true) + } + + private data class TodayStatsData( + val views: Int, + val visitors: Int, + val likes: Int, + val comments: Int + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4093727dcd96..664177c0c77e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -108,6 +108,7 @@ automattic-ucrop = '2.2.11' zendesk = '5.5.2' turbine = '1.2.1' commonmark = '0.27.1' +vico = '2.1.3' [libraries] airbnb-lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "airbnb-lottie" } @@ -267,6 +268,7 @@ automattic-ucrop = { group = "com.automattic", name = "ucrop", version.ref = "au zendesk-support = { group = "com.zendesk", name = "support", version.ref = "zendesk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" } +vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From ae2806a1fce64d057e6c52f96aa9ba696e62e294 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 16 Jan 2026 16:56:52 +0100 Subject: [PATCH 02/22] Add horuly granularity --- .../todaysstat/TodaysStatsViewModel.kt | 36 +++++++++++-------- .../refresh/utils/StatsAnalyticsUtils.kt | 2 ++ .../stats/refresh/utils/StatsDateFormatter.kt | 14 ++++++++ .../fluxc/network/utils/StatsGranularity.kt | 1 + .../fluxc/persistence/StatsSqlUtils.kt | 1 + .../fluxc/persistence/TimeStatsSqlUtils.kt | 2 ++ 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 87f9c513fee2..704f241fdba5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -18,8 +18,8 @@ import java.util.Calendar import java.util.Locale import javax.inject.Inject -private const val CHART_DATA_POINTS = 7 -private const val PREVIOUS_PERIOD_OFFSET_DAYS = 7 +private const val HOURLY_DATA_POINTS = 24 +private const val PREVIOUS_PERIOD_OFFSET_DAYS = 1 @HiltViewModel class TodaysStatsViewModel @Inject constructor( @@ -92,8 +92,8 @@ class TodaysStatsViewModel @Inject constructor( } private suspend fun fetchChartData(site: SiteModel, forced: Boolean): ChartData { - val currentPeriodData = fetchPeriodData(site, forced, offsetDays = 0) - val previousPeriodData = fetchPeriodData(site, forced, offsetDays = PREVIOUS_PERIOD_OFFSET_DAYS) + val currentPeriodData = fetchHourlyData(site, forced, offsetDays = 0) + val previousPeriodData = fetchHourlyData(site, forced, offsetDays = PREVIOUS_PERIOD_OFFSET_DAYS) return ChartData( currentPeriod = currentPeriodData, @@ -101,7 +101,7 @@ class TodaysStatsViewModel @Inject constructor( ) } - private suspend fun fetchPeriodData( + private suspend fun fetchHourlyData( site: SiteModel, forced: Boolean, offsetDays: Int @@ -113,8 +113,8 @@ class TodaysStatsViewModel @Inject constructor( val response = visitsAndViewsStore.fetchVisits( site = site, - granularity = StatsGranularity.DAYS, - limitMode = LimitMode.Top(CHART_DATA_POINTS), + granularity = StatsGranularity.HOURS, + limitMode = LimitMode.Top(HOURLY_DATA_POINTS), date = calendar.time, forced = forced ) @@ -124,23 +124,31 @@ class TodaysStatsViewModel @Inject constructor( return emptyList() } - val dateFormat = SimpleDateFormat("EEE", Locale.getDefault()) - return model.dates.map { periodData -> ViewsDataPoint( - label = formatPeriodLabel(periodData.period, dateFormat), + label = formatHourlyLabel(periodData.period), views = periodData.views ) } } - private fun formatPeriodLabel(period: String, dateFormat: SimpleDateFormat): String { + private fun formatHourlyLabel(period: String): String { return try { - val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + // API returns period in format "2024-01-16 14:00:00" for hourly data + val inputFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val outputFormat = SimpleDateFormat("ha", Locale.getDefault()) val date = inputFormat.parse(period) - date?.let { dateFormat.format(it) } ?: period + date?.let { outputFormat.format(it).lowercase() } ?: period } catch (e: Exception) { - period + // Fallback: try parsing just the hour if full format fails + try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + val outputFormat = SimpleDateFormat("ha", Locale.getDefault()) + val date = inputFormat.parse(period) + date?.let { outputFormat.format(it).lowercase() } ?: period + } catch (e2: Exception) { + period + } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsAnalyticsUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsAnalyticsUtils.kt index 5d9949745aa8..cb11fe38e70a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsAnalyticsUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsAnalyticsUtils.kt @@ -15,6 +15,7 @@ import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper private const val TAP_SOURCE_PROPERTY = "tap_source" private const val GRANULARITY_PROPERTY = "granularity" private const val PERIOD_PROPERTY = "period" +private const val HOURS_PROPERTY = "hours" private const val DAYS_PROPERTY = "days" private const val WEEKS_PROPERTY = "weeks" private const val MONTHS_PROPERTY = "months" @@ -60,6 +61,7 @@ fun AnalyticsTrackerWrapper.trackWithGranularity(stat: Stat, granularity: StatsG track(stat, mapOf(PERIOD_PROPERTY to getPropertyByGranularity(granularity))) private fun getPropertyByGranularity(granularity: StatsGranularity) = when (granularity) { + StatsGranularity.HOURS -> HOURS_PROPERTY StatsGranularity.DAYS -> DAYS_PROPERTY StatsGranularity.WEEKS -> WEEKS_PROPERTY StatsGranularity.MONTHS -> MONTHS_PROPERTY diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt index a2d88562cb5b..48c4402538f8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/utils/StatsDateFormatter.kt @@ -4,6 +4,7 @@ import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.utils.StatsGranularity import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.HOURS import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS @@ -106,6 +107,7 @@ class StatsDateFormatter */ fun printGranularDate(date: Date, granularity: StatsGranularity): String { return when (granularity) { + HOURS -> SimpleDateFormat("ha", localeManagerWrapper.getLocale()).format(date).lowercase() DAYS -> outputFormat.format(date) WEEKS -> { val endCalendar = Calendar.getInstance() @@ -136,6 +138,7 @@ class StatsDateFormatter */ private fun printTrafficGranularDate(date: Date, granularity: StatsGranularity): String { return when (granularity) { + HOURS -> SimpleDateFormat("ha", localeManagerWrapper.getLocale()).format(date).lowercase() DAYS -> outputFormatTrafficDays.format(date) WEEKS -> { val endCalendar = Calendar.getInstance() @@ -243,6 +246,17 @@ class StatsDateFormatter date: String ): Date { return when (granularity) { + HOURS -> { + // Hourly data format: "yyyy-MM-dd HH:mm:ss" or "yyyy-MM-dd HH:mm" + val hourlyFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) + val hourlyFormatAlt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.ROOT) + try { + hourlyFormat.parse(date) ?: hourlyFormatAlt.parse(date) + ?: throw RuntimeException("Unexpected date format") + } catch (e: ParseException) { + hourlyFormatAlt.parse(date) ?: throw RuntimeException("Unexpected date format") + } + } DAYS -> inputFormat.parse(date) ?: throw RuntimeException("Unexpected date format") WEEKS -> { // first four digits are the year diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/StatsGranularity.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/StatsGranularity.kt index db3bb02619f4..c3439044f892 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/StatsGranularity.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/StatsGranularity.kt @@ -1,6 +1,7 @@ package org.wordpress.android.fluxc.network.utils enum class StatsGranularity(private val value: String) { + HOURS("hour"), DAYS("day"), WEEKS("week"), MONTHS("month"), diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt index c8c22dc8dda3..0c049d7ceda7 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt @@ -140,6 +140,7 @@ class StatsSqlUtils @Inject constructor() { enum class StatsType { INSIGHTS, + HOUR, DAY, WEEK, MONTH, diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt index ab6d1ef49a70..5ab0fc14faa1 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt @@ -14,6 +14,7 @@ import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestC import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient.VisitsAndViewsResponse import org.wordpress.android.fluxc.network.utils.StatsGranularity import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.HOURS import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS @@ -239,6 +240,7 @@ open class TimeStatsSqlUtils( private fun StatsGranularity.toStatsType(): StatsType { return when (this) { + HOURS -> StatsType.HOUR DAYS -> StatsType.DAY WEEKS -> StatsType.WEEK MONTHS -> StatsType.MONTH From 656d93d596f19171dca355127beb9e5f87e1536f Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 16 Jan 2026 17:02:34 +0100 Subject: [PATCH 03/22] Some styling --- .../android/ui/newstats/NewStatsActivity.kt | 1 - .../ui/newstats/todaysstat/TodaysStatsCard.kt | 95 ++++++++++++------- 2 files changed, 63 insertions(+), 33 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 7730af1921c5..d4b0d874c288 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -139,7 +139,6 @@ private fun TrafficTabContent( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(16.dp) ) { TodaysStatsCard(uiState = uiState) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index 6337bd8de892..9fe1d8c1801b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.newstats.todaysstat 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 @@ -19,8 +20,6 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -49,8 +48,9 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -private val CardCornerRadius = 16.dp +private val CardCornerRadius = 10.dp private val CardPadding = 16.dp +private val CardMargin = 16.dp private val ChartHeight = 80.dp private val MetricIconSize = 16.dp private val MetricSpacing = 4.dp @@ -60,15 +60,19 @@ fun TodaysStatsCard( uiState: TodaysStatsCardUiState, modifier: Modifier = Modifier ) { - Card( + val borderColor = MaterialTheme.colorScheme.outlineVariant + + Box( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(CardCornerRadius)), - shape = RoundedCornerShape(CardCornerRadius), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + .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 TodaysStatsCardUiState.Loading -> LoadingContent() @@ -337,10 +341,7 @@ private fun formatStatValue(value: Int): String { @Composable private fun TodaysStatsCardLoadingPreview() { AppThemeM3 { - TodaysStatsCard( - uiState = TodaysStatsCardUiState.Loading, - modifier = Modifier.padding(16.dp) - ) + TodaysStatsCard(uiState = TodaysStatsCardUiState.Loading) } } @@ -356,27 +357,24 @@ private fun TodaysStatsCardLoadedPreview() { comments = 12, chartData = ChartData( currentPeriod = listOf( - ViewsDataPoint("Mon", 100), - ViewsDataPoint("Tue", 150), - ViewsDataPoint("Wed", 120), - ViewsDataPoint("Thu", 200), - ViewsDataPoint("Fri", 180), - ViewsDataPoint("Sat", 250), - ViewsDataPoint("Sun", 220) + ViewsDataPoint("12am", 100), + ViewsDataPoint("4am", 50), + ViewsDataPoint("8am", 150), + ViewsDataPoint("12pm", 200), + ViewsDataPoint("4pm", 180), + ViewsDataPoint("8pm", 250) ), previousPeriod = listOf( - ViewsDataPoint("Mon", 80), - ViewsDataPoint("Tue", 120), - ViewsDataPoint("Wed", 100), - ViewsDataPoint("Thu", 160), - ViewsDataPoint("Fri", 140), - ViewsDataPoint("Sat", 200), - ViewsDataPoint("Sun", 180) + ViewsDataPoint("12am", 80), + ViewsDataPoint("4am", 40), + ViewsDataPoint("8am", 120), + ViewsDataPoint("12pm", 160), + ViewsDataPoint("4pm", 140), + ViewsDataPoint("8pm", 200) ) ), onCardClick = {} - ), - modifier = Modifier.padding(16.dp) + ) ) } } @@ -389,8 +387,41 @@ private fun TodaysStatsCardErrorPreview() { uiState = TodaysStatsCardUiState.Error( message = "Failed to load stats", onRetry = {} - ), - modifier = Modifier.padding(16.dp) + ) + ) + } +} + +@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TodaysStatsCardLoadedDarkPreview() { + AppThemeM3 { + TodaysStatsCard( + uiState = TodaysStatsCardUiState.Loaded( + views = 1234, + visitors = 567, + likes = 89, + comments = 12, + chartData = ChartData( + currentPeriod = listOf( + ViewsDataPoint("12am", 100), + ViewsDataPoint("4am", 50), + ViewsDataPoint("8am", 150), + ViewsDataPoint("12pm", 200), + ViewsDataPoint("4pm", 180), + ViewsDataPoint("8pm", 250) + ), + previousPeriod = listOf( + ViewsDataPoint("12am", 80), + ViewsDataPoint("4am", 40), + ViewsDataPoint("8am", 120), + ViewsDataPoint("12pm", 160), + ViewsDataPoint("4pm", 140), + ViewsDataPoint("8pm", 200) + ) + ), + onCardClick = {} + ) ) } } From 8042b2dbb46afcf8989ebf2df7c8de96aab1b550 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 16 Jan 2026 17:08:12 +0100 Subject: [PATCH 04/22] Following iOS distribution --- .../ui/newstats/todaysstat/TodaysStatsCard.kt | 214 +++++++++++------- 1 file changed, 133 insertions(+), 81 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index 9fe1d8c1801b..d9f96af4dac1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -89,31 +89,74 @@ private fun LoadingContent() { .fillMaxWidth() .padding(CardPadding) ) { - CardHeader() - Spacer(modifier = Modifier.height(12.dp)) - // Placeholder for chart - Box( - modifier = Modifier - .fillMaxWidth() - .height(ChartHeight) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - ) - Spacer(modifier = Modifier.height(16.dp)) - // Placeholder for metrics + // Top section: Title + Chart placeholder Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - repeat(4) { + // Left: Title and date placeholder + Column(modifier = Modifier.weight(0.4f)) { + Box( + modifier = Modifier + .width(80.dp) + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .width(100.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) + } + // Right: Chart placeholder + Box( + modifier = Modifier + .weight(0.6f) + .height(ChartHeight) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + // Bottom section: Metrics placeholder + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + // Views placeholder (larger) + Column { Box( modifier = Modifier .width(60.dp) - .height(40.dp) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .width(80.dp) + .height(36.dp) .clip(RoundedCornerShape(4.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ) } + Spacer(modifier = Modifier.width(24.dp)) + // Other metrics placeholder + repeat(3) { + Box( + modifier = Modifier + .width(50.dp) + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) + Spacer(modifier = Modifier.width(16.dp)) + } } } } @@ -126,10 +169,21 @@ private fun LoadedContent(state: TodaysStatsCardUiState.Loaded) { .clickable(onClick = state.onCardClick) .padding(CardPadding) ) { - CardHeader() + // Top section: Title/Date on left, Chart on right + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + // Left: Title and date + TitleSection() + Spacer(modifier = Modifier.width(16.dp)) + // Right: Chart + Box(modifier = Modifier.weight(1f)) { + StatsChart(chartData = state.chartData) + } + } Spacer(modifier = Modifier.height(12.dp)) - StatsChart(chartData = state.chartData) - Spacer(modifier = Modifier.height(16.dp)) + // Bottom section: Metrics MetricsRow( views = state.views, visitors = state.visitors, @@ -147,7 +201,7 @@ private fun ErrorContent(state: TodaysStatsCardUiState.Error) { .padding(CardPadding), horizontalAlignment = Alignment.CenterHorizontally ) { - CardHeader() + TitleSection() Spacer(modifier = Modifier.height(24.dp)) Text( text = state.message, @@ -162,20 +216,17 @@ private fun ErrorContent(state: TodaysStatsCardUiState.Error) { } @Composable -private fun CardHeader() { +private fun TitleSection() { val dateFormat = remember { SimpleDateFormat("EEEE, MMM d", Locale.getDefault()) } val formattedDate = remember { dateFormat.format(Date()) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + Column { Text( - text = stringResource(R.string.stats_insights_today), + text = stringResource(R.string.stats_insights_today_stats), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) + Spacer(modifier = Modifier.height(2.dp)) Text( text = formattedDate, style = MaterialTheme.typography.bodySmall, @@ -256,75 +307,76 @@ private fun MetricsRow( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + verticalAlignment = Alignment.Bottom ) { - MetricItem( - icon = Icons.Default.Visibility, + // Views - prominent on the left + PrimaryMetricItem( value = formatStatValue(views), - label = stringResource(R.string.stats_views), - isPrimary = true - ) - MetricItem( - icon = Icons.Default.Person, - value = formatStatValue(visitors), - label = stringResource(R.string.stats_visitors) + label = stringResource(R.string.stats_views) ) - MetricItem( - icon = Icons.Default.FavoriteBorder, - value = formatStatValue(likes), - label = stringResource(R.string.stats_likes) + Spacer(modifier = Modifier.weight(1f)) + // Secondary metrics on the right + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SecondaryMetricItem( + icon = Icons.Default.Person, + value = formatStatValue(visitors) + ) + SecondaryMetricItem( + icon = Icons.Default.FavoriteBorder, + value = formatStatValue(likes) + ) + SecondaryMetricItem( + icon = Icons.Default.ChatBubbleOutline, + value = formatStatValue(comments) + ) + } + } +} + +@Composable +private fun PrimaryMetricItem( + value: String, + label: String +) { + Column { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - MetricItem( - icon = Icons.Default.ChatBubbleOutline, - value = formatStatValue(comments), - label = stringResource(R.string.stats_comments) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = value, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface ) } } @Composable -private fun MetricItem( +private fun SecondaryMetricItem( icon: ImageVector, - value: String, - label: String, - isPrimary: Boolean = false + value: String ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MetricSpacing) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MetricSpacing) - ) { - Icon( - imageVector = icon, - contentDescription = label, - modifier = Modifier.size(MetricIconSize), - tint = if (isPrimary) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - Text( - text = value, - style = if (isPrimary) { - MaterialTheme.typography.titleLarge - } else { - MaterialTheme.typography.titleMedium - }, - fontWeight = FontWeight.Bold, - color = if (isPrimary) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - } - ) - } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(MetricIconSize), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) } } From 7fb9d33059fcac46d98e340fe5b38ed8e5cfc246 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 16 Jan 2026 17:26:20 +0100 Subject: [PATCH 05/22] Adding yesterday's data --- .../ui/newstats/todaysstat/TodaysStatsCard.kt | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index d9f96af4dac1..1f9b8f3be7fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.todaysstat +import android.graphics.DashPathEffect import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -238,15 +239,16 @@ private fun TitleSection() { @Composable private fun StatsChart(chartData: ChartData) { val modelProducer = remember { CartesianChartModelProducer() } + val hasPreviousPeriod = chartData.previousPeriod.isNotEmpty() LaunchedEffect(chartData) { if (chartData.currentPeriod.isNotEmpty()) { modelProducer.runTransaction { lineSeries { + // Today's data (solid line) series(chartData.currentPeriod.map { it.views.toInt() }) - } - if (chartData.previousPeriod.isNotEmpty()) { - lineSeries { + // Yesterday's data (dashed line) - only if available + if (hasPreviousPeriod) { series(chartData.previousPeriod.map { it.views.toInt() }) } } @@ -275,19 +277,25 @@ private fun StatsChart(chartData: ChartData) { val primaryColor = MaterialTheme.colorScheme.primary val secondaryColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + // Create fill for dashed line (yesterday's data) + val secondaryFill = fill(secondaryColor) + val dashedLine = remember(secondaryFill) { + DashedLine(LineCartesianLayer.LineFill.single(secondaryFill)) + } + CartesianChartHost( chart = rememberCartesianChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( + // Today's line - solid with area fill LineCartesianLayer.Line( fill = LineCartesianLayer.LineFill.single(fill(primaryColor)), areaFill = LineCartesianLayer.AreaFill.single( fill(primaryColor.copy(alpha = 0.2f)) ) ), - LineCartesianLayer.Line( - fill = LineCartesianLayer.LineFill.single(fill(secondaryColor)) - ) + // Yesterday's line - dashed, no area fill + dashedLine ) ) ), @@ -477,3 +485,18 @@ private fun TodaysStatsCardLoadedDarkPreview() { ) } } + +/** + * Custom Line implementation that draws a dashed line. + * Used for showing yesterday's stats comparison in the chart. + */ +private class DashedLine( + fill: LineCartesianLayer.LineFill +) : LineCartesianLayer.Line(fill) { + init { + linePaint.apply { + strokeWidth = 4f + pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f) + } + } +} From 2fb77607cacd89374be1a324a464edaf8982094c Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 16 Jan 2026 17:38:23 +0100 Subject: [PATCH 06/22] Gradiente and style --- .../ui/newstats/todaysstat/TodaysStatsCard.kt | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index 1f9b8f3be7fb..a415d9c8693d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -40,9 +40,13 @@ import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart import com.patrykandpatrick.vico.compose.common.fill +import com.patrykandpatrick.vico.compose.common.shader.verticalGradient import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.lineSeries +import com.patrykandpatrick.vico.core.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.core.common.data.ExtraStore +import com.patrykandpatrick.vico.core.common.shader.ShaderProvider import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 import java.text.SimpleDateFormat @@ -277,26 +281,50 @@ private fun StatsChart(chartData: ChartData) { val primaryColor = MaterialTheme.colorScheme.primary val secondaryColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + // Create gradient shader for area fill (primary color fading to transparent) + val areaGradient = ShaderProvider.verticalGradient( + colors = arrayOf( + primaryColor.copy(alpha = 0.8f), + primaryColor.copy(alpha = 0f) + ) + ) + // Create fill for dashed line (yesterday's data) val secondaryFill = fill(secondaryColor) val dashedLine = remember(secondaryFill) { DashedLine(LineCartesianLayer.LineFill.single(secondaryFill)) } + // Custom range provider that fits data to chart height (no forced zero baseline) + val rangeProvider = remember { + object : CartesianLayerRangeProvider { + override fun getMinY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { + // Use actual minimum with small padding below + val range = maxY - minY + return if (range > 0) minY - (range * 0.1) else 0.0 + } + + override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { + // Use actual maximum with small padding above + val range = maxY - minY + return if (range > 0) maxY + (range * 0.1) else 1.0 + } + } + } + CartesianChartHost( chart = rememberCartesianChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - // Today's line - solid with area fill + // Today's line - solid with gradient area fill LineCartesianLayer.Line( fill = LineCartesianLayer.LineFill.single(fill(primaryColor)), - areaFill = LineCartesianLayer.AreaFill.single( - fill(primaryColor.copy(alpha = 0.2f)) - ) + areaFill = LineCartesianLayer.AreaFill.single(fill(areaGradient)) ), // Yesterday's line - dashed, no area fill dashedLine - ) + ), + rangeProvider = rangeProvider ) ), modelProducer = modelProducer, From 57600d9912b129fe8ff5f6475f2513d954d358df Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 16 Jan 2026 18:20:43 +0100 Subject: [PATCH 07/22] Line fixes --- .../ui/newstats/todaysstat/TodaysStatsCard.kt | 59 ++++--------------- 1 file changed, 13 insertions(+), 46 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index a415d9c8693d..efa926e4679c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -1,6 +1,5 @@ package org.wordpress.android.ui.newstats.todaysstat -import android.graphics.DashPathEffect import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -37,15 +36,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart import com.patrykandpatrick.vico.compose.common.fill import com.patrykandpatrick.vico.compose.common.shader.verticalGradient import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.lineSeries -import com.patrykandpatrick.vico.core.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.core.common.data.ExtraStore import com.patrykandpatrick.vico.core.common.shader.ShaderProvider import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -289,45 +287,27 @@ private fun StatsChart(chartData: ChartData) { ) ) - // Create fill for dashed line (yesterday's data) - val secondaryFill = fill(secondaryColor) - val dashedLine = remember(secondaryFill) { - DashedLine(LineCartesianLayer.LineFill.single(secondaryFill)) - } - - // Custom range provider that fits data to chart height (no forced zero baseline) - val rangeProvider = remember { - object : CartesianLayerRangeProvider { - override fun getMinY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { - // Use actual minimum with small padding below - val range = maxY - minY - return if (range > 0) minY - (range * 0.1) else 0.0 - } - - override fun getMaxY(minY: Double, maxY: Double, extraStore: ExtraStore): Double { - // Use actual maximum with small padding above - val range = maxY - minY - return if (range > 0) maxY + (range * 0.1) else 1.0 - } - } - } - CartesianChartHost( chart = rememberCartesianChart( rememberLineCartesianLayer( lineProvider = LineCartesianLayer.LineProvider.series( - // Today's line - solid with gradient area fill + // Today's line - solid with gradient area fill, curved LineCartesianLayer.Line( fill = LineCartesianLayer.LineFill.single(fill(primaryColor)), - areaFill = LineCartesianLayer.AreaFill.single(fill(areaGradient)) + areaFill = LineCartesianLayer.AreaFill.single(fill(areaGradient)), + pointConnector = LineCartesianLayer.PointConnector.cubic() ), - // Yesterday's line - dashed, no area fill - dashedLine - ), - rangeProvider = rangeProvider + // Yesterday's line - dashed, no area fill, curved + LineCartesianLayer.Line( + fill = LineCartesianLayer.LineFill.single(fill(secondaryColor)), + stroke = LineCartesianLayer.LineStroke.Dashed(), + pointConnector = LineCartesianLayer.PointConnector.cubic() + ) + ) ) ), modelProducer = modelProducer, + scrollState = rememberVicoScrollState(scrollEnabled = false), modifier = Modifier .fillMaxWidth() .height(ChartHeight) @@ -514,17 +494,4 @@ private fun TodaysStatsCardLoadedDarkPreview() { } } -/** - * Custom Line implementation that draws a dashed line. - * Used for showing yesterday's stats comparison in the chart. - */ -private class DashedLine( - fill: LineCartesianLayer.LineFill -) : LineCartesianLayer.Line(fill) { - init { - linePaint.apply { - strokeWidth = 4f - pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f) - } - } -} + From b532c6e603edc7f20abf90248f4ccb273875f525 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 19 Jan 2026 10:01:37 +0100 Subject: [PATCH 08/22] detekt --- .../org/wordpress/android/ui/newstats/NewStatsActivity.kt | 1 - .../android/ui/newstats/todaysstat/TodaysStatsCard.kt | 8 ++++---- .../ui/newstats/todaysstat/TodaysStatsViewModel.kt | 2 ++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index d4b0d874c288..633122f8a865 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index efa926e4679c..a759c2988167 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -18,7 +18,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChatBubbleOutline import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -29,7 +28,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -57,6 +55,8 @@ private val CardMargin = 16.dp private val ChartHeight = 80.dp private val MetricIconSize = 16.dp private val MetricSpacing = 4.dp +private const val THOUSAND = 1_000 +private const val MILLION = 1_000_000 @Composable fun TodaysStatsCard( @@ -399,8 +399,8 @@ private fun SecondaryMetricItem( private fun formatStatValue(value: Int): String { return when { - value >= 1_000_000 -> String.format(Locale.getDefault(), "%.1fM", value / 1_000_000.0) - value >= 1_000 -> String.format(Locale.getDefault(), "%.1fK", value / 1_000.0) + value >= MILLION -> String.format(Locale.getDefault(), "%.1fM", value / MILLION.toDouble()) + value >= THOUSAND -> String.format(Locale.getDefault(), "%.1fK", value / THOUSAND.toDouble()) else -> value.toString() } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 704f241fdba5..9c1aa116bd0e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -34,6 +34,7 @@ class TodaysStatsViewModel @Inject constructor( loadData() } + @Suppress("TooGenericExceptionCaught") fun loadData(forced: Boolean = false) { val site = selectedSiteRepository.getSelectedSite() if (site == null) { @@ -132,6 +133,7 @@ class TodaysStatsViewModel @Inject constructor( } } + @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun formatHourlyLabel(period: String): String { return try { // API returns period in format "2024-01-16 14:00:00" for hourly data From 122fbada48b05208fe7c810ce0e2e77128991a1a Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 19 Jan 2026 10:20:57 +0100 Subject: [PATCH 09/22] Adding tests --- .../todaysstat/TodaysStatsViewModelTest.kt | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt new file mode 100644 index 000000000000..bc193565c6a2 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -0,0 +1,348 @@ +package org.wordpress.android.ui.newstats.todaysstat + +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.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.VisitsModel +import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType +import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore +import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository + +@ExperimentalCoroutinesApi +class TodaysStatsViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var todayInsightsStore: TodayInsightsStore + + @Mock + private lateinit var visitsAndViewsStore: VisitsAndViewsStore + + private lateinit var viewModel: TodaysStatsViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + @Before + fun setUp() { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) + } + + private fun initViewModel() { + viewModel = TodaysStatsViewModel( + selectedSiteRepository, + todayInsightsStore, + visitsAndViewsStore + ) + } + + @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(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo("No site selected") + } + + @Test + fun `when data loads successfully, then loaded state is emitted with correct values`() = test { + val visitsModel = VisitsModel( + period = "2024-01-16", + views = TEST_VIEWS, + visitors = TEST_VISITORS, + likes = TEST_LIKES, + reblogs = 0, + comments = TEST_COMMENTS, + posts = 0 + ) + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + with(state as TodaysStatsCardUiState.Loaded) { + assertThat(views).isEqualTo(TEST_VIEWS) + assertThat(visitors).isEqualTo(TEST_VISITORS) + assertThat(likes).isEqualTo(TEST_LIKES) + assertThat(comments).isEqualTo(TEST_COMMENTS) + } + } + + @Test + fun `when today insights fetch fails, then error state is emitted`() = test { + val error = StatsError(StatsErrorType.GENERIC_ERROR, "Network error") + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(error)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo("Failed to load stats") + } + + @Test + fun `when today insights returns null model, then error state is emitted`() = test { + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(model = null)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + } + + @Test + fun `when visits and views fetch fails, then chart data is empty but state is loaded`() = test { + val visitsModel = createVisitsModel() + val error = StatsError(StatsErrorType.GENERIC_ERROR, "Network error") + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(error)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + with(state as TodaysStatsCardUiState.Loaded) { + assertThat(chartData.currentPeriod).isEmpty() + assertThat(chartData.previousPeriod).isEmpty() + } + } + + @Test + fun `when loadData is called with forced true, then stores are called with forced true`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + viewModel.loadData(forced = true) + advanceUntilIdle() + + verify(todayInsightsStore).fetchTodayInsights(eq(testSite), eq(true)) + } + + @Test + fun `when onRetry is called, then loadData is called with forced true`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + viewModel.onRetry() + advanceUntilIdle() + + verify(todayInsightsStore).fetchTodayInsights(eq(testSite), eq(true)) + } + + @Test + fun `when data loads, then chart data contains current and previous period data`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + with(state as TodaysStatsCardUiState.Loaded) { + assertThat(chartData.currentPeriod).hasSize(2) + assertThat(chartData.previousPeriod).hasSize(2) + } + } + + @Test + fun `when fetch visits is called, then hourly granularity is used for both periods`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + // fetchVisits is called twice: once for current period, once for previous period + verify(visitsAndViewsStore, times(2)).fetchVisits( + site = eq(testSite), + granularity = eq(StatsGranularity.HOURS), + limitMode = any(), + date = any(), + forced = any(), + applySiteTimezone = any() + ) + } + + @Test + fun `when exception is thrown during fetch, then error state is emitted with exception message`() = test { + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenThrow(RuntimeException("Test exception")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo("Test exception") + } + + @Test + fun `when exception with null message is thrown, then error state has unknown error message`() = test { + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenThrow(RuntimeException()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo("Unknown error") + } + + @Test + fun `when loadData is called again, then state transitions through loading`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + // Verify we're in Loaded state first + assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + + // Call loadData again and verify the final state is still Loaded + viewModel.loadData(forced = true) + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + } + + @Test + fun `when error state retry is clicked, then data is reloaded`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val errorState = viewModel.uiState.value as TodaysStatsCardUiState.Error + + // Now set up for successful reload + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(createVisitsModel())) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + + errorState.onRetry() + advanceUntilIdle() + + assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + } + + private fun createVisitsModel() = VisitsModel( + period = "2024-01-16", + views = TEST_VIEWS, + visitors = TEST_VISITORS, + likes = TEST_LIKES, + reblogs = 0, + comments = TEST_COMMENTS, + posts = 0 + ) + + private fun createVisitsAndViewsModel() = VisitsAndViewsModel( + period = "hour", + dates = listOf( + VisitsAndViewsModel.PeriodData( + period = "2024-01-16 14:00:00", + views = 100L, + visitors = 50L, + likes = 10L, + reblogs = 0L, + comments = 5L, + posts = 0L + ), + VisitsAndViewsModel.PeriodData( + period = "2024-01-16 15:00:00", + views = 150L, + visitors = 75L, + likes = 15L, + reblogs = 0L, + comments = 8L, + posts = 0L + ) + ) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_VIEWS = 500 + private const val TEST_VISITORS = 100 + private const val TEST_LIKES = 50 + private const val TEST_COMMENTS = 25 + } +} From f5004111bd8a89ecb4a2bf4e8b74a9a9aaa595d1 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 19 Jan 2026 10:21:05 +0100 Subject: [PATCH 10/22] trailing line --- .../wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index a759c2988167..5b4b18662cb6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -493,5 +493,3 @@ private fun TodaysStatsCardLoadedDarkPreview() { ) } } - - From 89afd046538bfe8688ded34c4521d794d6aa9db2 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 19 Jan 2026 10:35:27 +0100 Subject: [PATCH 11/22] Extractign strings --- .../todaysstat/TodaysStatsViewModel.kt | 11 +++++---- WordPress/src/main/res/values/strings.xml | 3 +++ .../todaysstat/TodaysStatsViewModelTest.kt | 23 +++++++++++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 9c1aa116bd0e..a0cb3c4834c1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -7,12 +7,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.stats.LimitMode import org.wordpress.android.fluxc.network.utils.StatsGranularity import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.viewmodel.ResourceProvider import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -25,7 +27,8 @@ private const val PREVIOUS_PERIOD_OFFSET_DAYS = 1 class TodaysStatsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val todayInsightsStore: TodayInsightsStore, - private val visitsAndViewsStore: VisitsAndViewsStore + private val visitsAndViewsStore: VisitsAndViewsStore, + private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow(TodaysStatsCardUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -39,7 +42,7 @@ class TodaysStatsViewModel @Inject constructor( val site = selectedSiteRepository.getSelectedSite() if (site == null) { _uiState.value = TodaysStatsCardUiState.Error( - message = "No site selected", + message = resourceProvider.getString(R.string.stats_todays_stats_no_site_selected), onRetry = { loadData(forced = true) } ) return @@ -63,13 +66,13 @@ class TodaysStatsViewModel @Inject constructor( ) } else { _uiState.value = TodaysStatsCardUiState.Error( - message = "Failed to load stats", + message = resourceProvider.getString(R.string.stats_todays_stats_failed_to_load), onRetry = { loadData(forced = true) } ) } } catch (e: Exception) { _uiState.value = TodaysStatsCardUiState.Error( - message = e.message ?: "Unknown error", + message = e.message ?: resourceProvider.getString(R.string.stats_todays_stats_unknown_error), onRetry = { loadData(forced = true) } ) } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 8fb247270ee4..c06c00d5b168 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1549,6 +1549,9 @@ Data not loaded There was a problem loading your data, refresh your page to try again. + No site selected + Failed to load stats + Unknown error Open Website diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt index bc193565c6a2..14ff09f07ac5 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -19,9 +19,11 @@ import org.wordpress.android.fluxc.network.utils.StatsGranularity import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched import org.wordpress.android.fluxc.store.StatsStore.StatsError import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType +import org.wordpress.android.R import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi class TodaysStatsViewModelTest : BaseUnitTest() { @@ -34,6 +36,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Mock private lateinit var visitsAndViewsStore: VisitsAndViewsStore + @Mock + private lateinit var resourceProvider: ResourceProvider + private lateinit var viewModel: TodaysStatsViewModel private val testSite = SiteModel().apply { @@ -45,13 +50,20 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) + whenever(resourceProvider.getString(R.string.stats_todays_stats_no_site_selected)) + .thenReturn(NO_SITE_SELECTED_ERROR) + whenever(resourceProvider.getString(R.string.stats_todays_stats_failed_to_load)) + .thenReturn(FAILED_TO_LOAD_ERROR) + whenever(resourceProvider.getString(R.string.stats_todays_stats_unknown_error)) + .thenReturn(UNKNOWN_ERROR) } private fun initViewModel() { viewModel = TodaysStatsViewModel( selectedSiteRepository, todayInsightsStore, - visitsAndViewsStore + visitsAndViewsStore, + resourceProvider ) } @@ -64,7 +76,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) - assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo("No site selected") + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo(NO_SITE_SELECTED_ERROR) } @Test @@ -112,7 +124,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) - assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo("Failed to load stats") + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo(FAILED_TO_LOAD_ERROR) } @Test @@ -256,7 +268,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) - assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo("Unknown error") + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo(UNKNOWN_ERROR) } @Test @@ -344,5 +356,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { private const val TEST_VISITORS = 100 private const val TEST_LIKES = 50 private const val TEST_COMMENTS = 25 + private const val NO_SITE_SELECTED_ERROR = "No site selected" + private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = "Unknown error" } } From 69a4882bbab9b496a3db7e557f4f65f50cc15c5d Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 19 Jan 2026 11:07:40 +0100 Subject: [PATCH 12/22] Adding pull to refresh --- .../android/ui/newstats/NewStatsActivity.kt | 32 +++++++-- .../todaysstat/TodaysStatsViewModel.kt | 68 +++++++++++++------ .../todaysstat/TodaysStatsViewModelTest.kt | 68 +++++++++++++++++++ 3 files changed, 141 insertions(+), 27 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 633122f8a865..8a9367383372 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -17,11 +17,15 @@ 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.PrimaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -128,18 +132,36 @@ private fun StatsTabContent(tab: StatsTab) { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TrafficTabContent( viewModel: TodaysStatsViewModel = viewModel() ) { val uiState by viewModel.uiState.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val pullToRefreshState = rememberPullToRefreshState() - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + isRefreshing = isRefreshing, + state = pullToRefreshState, + onRefresh = { viewModel.refresh() }, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopCenter) + ) + } ) { - TodaysStatsCard(uiState = uiState) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + TodaysStatsCard(uiState = uiState) + } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index a0cb3c4834c1..1ae5fef71daf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -33,11 +33,21 @@ class TodaysStatsViewModel @Inject constructor( private val _uiState = MutableStateFlow(TodaysStatsCardUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + init { loadData() } - @Suppress("TooGenericExceptionCaught") + fun refresh() { + viewModelScope.launch { + _isRefreshing.value = true + loadDataInternal(forced = true) + _isRefreshing.value = false + } + } + fun loadData(forced: Boolean = false) { val site = selectedSiteRepository.getSelectedSite() if (site == null) { @@ -51,31 +61,45 @@ class TodaysStatsViewModel @Inject constructor( _uiState.value = TodaysStatsCardUiState.Loading viewModelScope.launch { - try { - val todayStats = fetchTodayStats(site, forced) - val chartData = fetchChartData(site, forced) - - if (todayStats != null) { - _uiState.value = TodaysStatsCardUiState.Loaded( - views = todayStats.views, - visitors = todayStats.visitors, - likes = todayStats.likes, - comments = todayStats.comments, - chartData = chartData, - onCardClick = { onCardClicked() } - ) - } else { - _uiState.value = TodaysStatsCardUiState.Error( - message = resourceProvider.getString(R.string.stats_todays_stats_failed_to_load), - onRetry = { loadData(forced = true) } - ) - } - } catch (e: Exception) { + loadDataInternal(forced) + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadDataInternal(forced: Boolean) { + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + _uiState.value = TodaysStatsCardUiState.Error( + message = resourceProvider.getString(R.string.stats_todays_stats_no_site_selected), + onRetry = { loadData(forced = true) } + ) + return + } + + try { + val todayStats = fetchTodayStats(site, forced) + val chartData = fetchChartData(site, forced) + + if (todayStats != null) { + _uiState.value = TodaysStatsCardUiState.Loaded( + views = todayStats.views, + visitors = todayStats.visitors, + likes = todayStats.likes, + comments = todayStats.comments, + chartData = chartData, + onCardClick = { onCardClicked() } + ) + } else { _uiState.value = TodaysStatsCardUiState.Error( - message = e.message ?: resourceProvider.getString(R.string.stats_todays_stats_unknown_error), + message = resourceProvider.getString(R.string.stats_todays_stats_failed_to_load), onRetry = { loadData(forced = true) } ) } + } catch (e: Exception) { + _uiState.value = TodaysStatsCardUiState.Error( + message = e.message ?: resourceProvider.getString(R.string.stats_todays_stats_unknown_error), + onRetry = { loadData(forced = true) } + ) } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt index 14ff09f07ac5..00f28d51e6f5 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -316,6 +316,74 @@ class TodaysStatsViewModelTest : BaseUnitTest() { assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) } + @Test + fun `when refresh is called, then isRefreshing becomes true then false`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + // Verify initial state is not refreshing + assertThat(viewModel.isRefreshing.value).isFalse() + + viewModel.refresh() + advanceUntilIdle() + + // After refresh completes, isRefreshing should be false + assertThat(viewModel.isRefreshing.value).isFalse() + } + + @Test + fun `when refresh is called, then data is fetched with forced true`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + // Verify that fetchTodayInsights was called with forced = true during refresh + // (called twice: once during init, once during refresh) + verify(todayInsightsStore, times(2)).fetchTodayInsights(eq(testSite), any()) + verify(todayInsightsStore).fetchTodayInsights(eq(testSite), eq(true)) + } + + @Test + fun `when refresh is called, then state remains loaded without showing loading state`() = test { + val visitsModel = createVisitsModel() + val visitsAndViewsModel = createVisitsAndViewsModel() + + whenever(todayInsightsStore.fetchTodayInsights(any(), any())) + .thenReturn(OnStatsFetched(visitsModel)) + whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) + .thenReturn(OnStatsFetched(visitsAndViewsModel)) + + initViewModel() + advanceUntilIdle() + + // Verify initial state is Loaded + assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + + viewModel.refresh() + advanceUntilIdle() + + // State should still be Loaded after refresh (not showing Loading state) + assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) + } + private fun createVisitsModel() = VisitsModel( period = "2024-01-16", views = TEST_VIEWS, From 1b5c9c014228b14dae034216bb7dbaeac7d001b1 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 19 Jan 2026 15:32:35 +0100 Subject: [PATCH 13/22] Using rust library --- .../todaysstat/TodaysStatsRepository.kt | 147 ++++++++++++++++++ .../todaysstat/TodaysStatsViewModel.kt | 62 ++++---- .../todaysstat/TodaysStatsViewModelTest.kt | 117 ++++++-------- gradle/libs.versions.toml | 2 +- 4 files changed, 225 insertions(+), 103 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt new file mode 100644 index 000000000000..facd78e0d4a0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt @@ -0,0 +1,147 @@ +package org.wordpress.android.ui.newstats.todaysstat + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import org.wordpress.android.util.AppLog +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.StatsVisitsDataValue +import uniffi.wp_api.StatsVisitsParams +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named + +private const val HOURLY_UNIT = "hour" +private const val HOURLY_QUANTITY = 24u + +/** + * Repository for fetching stats data using the wordpress-rs API. + * Handles hourly visits/views data for the Today's Stats card chart. + */ +class TodaysStatsRepository @Inject constructor( + private val wpComApiClientProvider: WpComApiClientProvider, + private val appLogWrapper: AppLogWrapper, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, +) { + /** + * Access token for API authentication. + * Marked as @Volatile to ensure visibility across threads since this repository is accessed + * from multiple coroutine contexts (main thread initialization, IO dispatcher for API calls). + */ + @Volatile + private var accessToken: String? = null + + private val wpComApiClient: WpComApiClient by lazy { + check(accessToken != null) { "Repository not initialized" } + wpComApiClientProvider.getWpComApiClient(accessToken!!) + } + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + + fun init(accessToken: String) { + this.accessToken = accessToken + } + + /** + * Fetches hourly views data for the specified date. + * + * @param siteId The WordPress.com site ID + * @param offsetDays Number of days to offset from today (0 = today, 1 = yesterday, etc.) + * @return List of hourly views data points, or empty list if fetch fails + */ + suspend fun fetchHourlyViews( + siteId: Long, + offsetDays: Int = 0 + ): HourlyViewsResult = withContext(ioDispatcher) { + if (accessToken == null) { + appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + return@withContext HourlyViewsResult.Error("Repository not initialized") + } + + val calendar = Calendar.getInstance() + if (offsetDays > 0) { + calendar.add(Calendar.DAY_OF_YEAR, -offsetDays) + } + val dateString = dateFormat.format(calendar.time) + + val params = StatsVisitsParams( + unit = HOURLY_UNIT, + quantity = HOURLY_QUANTITY, + date = dateString, + locale = Locale.getDefault().language + ) + + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsVisits().getStatsVisits( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + when (result) { + is WpRequestResult.Success -> { + val response = result.response.data + val dataPoints = response.data.mapNotNull { row -> + parseDataRow(row) + } + HourlyViewsResult.Success(dataPoints) + } + + is WpRequestResult.WpError -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching hourly views: ${result.errorMessage}") + HourlyViewsResult.Error(result.errorMessage) + } + + else -> { + appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching hourly views") + HourlyViewsResult.Error("Unknown error") + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun parseDataRow(row: Any?): HourlyViewsDataPoint? { + return try { + val rowList = row as? List<*> ?: return null + val periodValue = rowList.getOrNull(0) + val viewsValue = rowList.getOrNull(1) + + // Extract values from wrapper types + val period = when (periodValue) { + is StatsVisitsDataValue.String -> periodValue.v1 + else -> return null + } + + val views = when (viewsValue) { + is StatsVisitsDataValue.Number -> viewsValue.v1.toLong() + else -> 0L + } + + HourlyViewsDataPoint(period = period, views = views) + } catch (e: Exception) { + appLogWrapper.w(AppLog.T.STATS, "Failed to parse stats row: ${e.message}") + null + } + } +} + +/** + * Result wrapper for hourly views fetch operation. + */ +sealed class HourlyViewsResult { + data class Success(val dataPoints: List) : HourlyViewsResult() + data class Error(val message: String) : HourlyViewsResult() +} + +/** + * Raw data point from the stats API. + */ +data class HourlyViewsDataPoint( + val period: String, + val views: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 1ae5fef71daf..1b2638805852 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -9,25 +9,22 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.model.stats.LimitMode -import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore -import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.viewmodel.ResourceProvider import java.text.SimpleDateFormat -import java.util.Calendar import java.util.Locale import javax.inject.Inject -private const val HOURLY_DATA_POINTS = 24 private const val PREVIOUS_PERIOD_OFFSET_DAYS = 1 @HiltViewModel class TodaysStatsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, private val todayInsightsStore: TodayInsightsStore, - private val visitsAndViewsStore: VisitsAndViewsStore, + private val todaysStatsRepository: TodaysStatsRepository, private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow(TodaysStatsCardUiState.Loading) @@ -58,6 +55,16 @@ class TodaysStatsViewModel @Inject constructor( return } + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + _uiState.value = TodaysStatsCardUiState.Error( + message = resourceProvider.getString(R.string.stats_todays_stats_failed_to_load), + onRetry = { loadData(forced = true) } + ) + return + } + + todaysStatsRepository.init(accessToken) _uiState.value = TodaysStatsCardUiState.Loading viewModelScope.launch { @@ -78,7 +85,7 @@ class TodaysStatsViewModel @Inject constructor( try { val todayStats = fetchTodayStats(site, forced) - val chartData = fetchChartData(site, forced) + val chartData = fetchChartData(site) if (todayStats != null) { _uiState.value = TodaysStatsCardUiState.Loaded( @@ -119,9 +126,9 @@ class TodaysStatsViewModel @Inject constructor( } } - private suspend fun fetchChartData(site: SiteModel, forced: Boolean): ChartData { - val currentPeriodData = fetchHourlyData(site, forced, offsetDays = 0) - val previousPeriodData = fetchHourlyData(site, forced, offsetDays = PREVIOUS_PERIOD_OFFSET_DAYS) + private suspend fun fetchChartData(site: SiteModel): ChartData { + val currentPeriodData = fetchHourlyData(site, offsetDays = 0) + val previousPeriodData = fetchHourlyData(site, offsetDays = PREVIOUS_PERIOD_OFFSET_DAYS) return ChartData( currentPeriod = currentPeriodData, @@ -131,32 +138,23 @@ class TodaysStatsViewModel @Inject constructor( private suspend fun fetchHourlyData( site: SiteModel, - forced: Boolean, offsetDays: Int ): List { - val calendar = Calendar.getInstance() - if (offsetDays > 0) { - calendar.add(Calendar.DAY_OF_YEAR, -offsetDays) - } - - val response = visitsAndViewsStore.fetchVisits( - site = site, - granularity = StatsGranularity.HOURS, - limitMode = LimitMode.Top(HOURLY_DATA_POINTS), - date = calendar.time, - forced = forced + val result = todaysStatsRepository.fetchHourlyViews( + siteId = site.siteId, + offsetDays = offsetDays ) - val model = response.model - if (response.isError || model == null) { - return emptyList() - } - - return model.dates.map { periodData -> - ViewsDataPoint( - label = formatHourlyLabel(periodData.period), - views = periodData.views - ) + return when (result) { + is HourlyViewsResult.Success -> { + result.dataPoints.map { dataPoint -> + ViewsDataPoint( + label = formatHourlyLabel(dataPoint.period), + views = dataPoint.views + ) + } + } + is HourlyViewsResult.Error -> emptyList() } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt index 00f28d51e6f5..2e4bbb6a2b8c 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -12,16 +12,13 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.model.stats.LimitMode import org.wordpress.android.fluxc.model.stats.VisitsModel -import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel -import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched import org.wordpress.android.fluxc.store.StatsStore.StatsError import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType import org.wordpress.android.R import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore -import org.wordpress.android.fluxc.store.stats.time.VisitsAndViewsStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.viewmodel.ResourceProvider @@ -30,11 +27,14 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Mock private lateinit var selectedSiteRepository: SelectedSiteRepository + @Mock + private lateinit var accountStore: AccountStore + @Mock private lateinit var todayInsightsStore: TodayInsightsStore @Mock - private lateinit var visitsAndViewsStore: VisitsAndViewsStore + private lateinit var todaysStatsRepository: TodaysStatsRepository @Mock private lateinit var resourceProvider: ResourceProvider @@ -50,6 +50,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) + whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) whenever(resourceProvider.getString(R.string.stats_todays_stats_no_site_selected)) .thenReturn(NO_SITE_SELECTED_ERROR) whenever(resourceProvider.getString(R.string.stats_todays_stats_failed_to_load)) @@ -61,8 +62,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { private fun initViewModel() { viewModel = TodaysStatsViewModel( selectedSiteRepository, + accountStore, todayInsightsStore, - visitsAndViewsStore, + todaysStatsRepository, resourceProvider ) } @@ -90,12 +92,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { comments = TEST_COMMENTS, posts = 0 ) - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -116,8 +117,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(error)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -131,8 +132,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when today insights returns null model, then error state is emitted`() = test { whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(model = null)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -142,14 +143,13 @@ class TodaysStatsViewModelTest : BaseUnitTest() { } @Test - fun `when visits and views fetch fails, then chart data is empty but state is loaded`() = test { + fun `when hourly views fetch fails, then chart data is empty but state is loaded`() = test { val visitsModel = createVisitsModel() - val error = StatsError(StatsErrorType.GENERIC_ERROR, "Network error") whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(error)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(HourlyViewsResult.Error("Network error")) initViewModel() advanceUntilIdle() @@ -165,12 +165,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when loadData is called with forced true, then stores are called with forced true`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -184,12 +183,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when onRetry is called, then loadData is called with forced true`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -203,12 +201,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when data loads, then chart data contains current and previous period data`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -222,27 +219,21 @@ class TodaysStatsViewModelTest : BaseUnitTest() { } @Test - fun `when fetch visits is called, then hourly granularity is used for both periods`() = test { + fun `when fetch hourly views is called, then repository is called for both periods`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() - // fetchVisits is called twice: once for current period, once for previous period - verify(visitsAndViewsStore, times(2)).fetchVisits( - site = eq(testSite), - granularity = eq(StatsGranularity.HOURS), - limitMode = any(), - date = any(), - forced = any(), - applySiteTimezone = any() - ) + // fetchHourlyViews is called twice: once for current period (offsetDays=0), + // once for previous period (offsetDays=1) + verify(todaysStatsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(0)) + verify(todaysStatsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(1)) } @Test @@ -274,12 +265,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when loadData is called again, then state transitions through loading`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -307,8 +297,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(createVisitsModel())) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(createVisitsAndViewsModel())) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) errorState.onRetry() advanceUntilIdle() @@ -319,12 +309,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when refresh is called, then isRefreshing becomes true then false`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -342,12 +331,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when refresh is called, then data is fetched with forced true`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -364,12 +352,11 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when refresh is called, then state remains loaded without showing loading state`() = test { val visitsModel = createVisitsModel() - val visitsAndViewsModel = createVisitsAndViewsModel() whenever(todayInsightsStore.fetchTodayInsights(any(), any())) .thenReturn(OnStatsFetched(visitsModel)) - whenever(visitsAndViewsStore.fetchVisits(any(), any(), any(), any(), any(), any())) - .thenReturn(OnStatsFetched(visitsAndViewsModel)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() @@ -394,32 +381,22 @@ class TodaysStatsViewModelTest : BaseUnitTest() { posts = 0 ) - private fun createVisitsAndViewsModel() = VisitsAndViewsModel( - period = "hour", - dates = listOf( - VisitsAndViewsModel.PeriodData( + private fun createHourlyViewsResult() = HourlyViewsResult.Success( + listOf( + HourlyViewsDataPoint( period = "2024-01-16 14:00:00", - views = 100L, - visitors = 50L, - likes = 10L, - reblogs = 0L, - comments = 5L, - posts = 0L + views = 100L ), - VisitsAndViewsModel.PeriodData( + HourlyViewsDataPoint( period = "2024-01-16 15:00:00", - views = 150L, - visitors = 75L, - likes = 15L, - reblogs = 0L, - comments = 8L, - posts = 0L + views = 150L ) ) ) companion object { private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" private const val TEST_VIEWS = 500 private const val TEST_VISITORS = 100 private const val TEST_LIKES = 50 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 664177c0c77e..ab891c3fc2d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-21200d63735de310067d88c7dfb6080a2200f18e' +wordpress-rs = '1106-305ea6d1ba5673f870c23c78f6dbbda86af4778d' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2' From 0090c424af6b1e29a867c3076b6e416987f7b5eb Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 19 Jan 2026 16:03:47 +0100 Subject: [PATCH 14/22] Fixing date period --- .../android/ui/newstats/todaysstat/TodaysStatsRepository.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt index facd78e0d4a0..3b5531cc65a6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt @@ -64,16 +64,13 @@ class TodaysStatsRepository @Inject constructor( } val calendar = Calendar.getInstance() - if (offsetDays > 0) { - calendar.add(Calendar.DAY_OF_YEAR, -offsetDays) - } + calendar.add(Calendar.DAY_OF_YEAR, 1 - offsetDays) val dateString = dateFormat.format(calendar.time) val params = StatsVisitsParams( unit = HOURLY_UNIT, quantity = HOURLY_QUANTITY, date = dateString, - locale = Locale.getDefault().language ) val result = wpComApiClient.request { requestBuilder -> From 8b0f0bc5dd5280551e4e9636afc6368c5ff55c6f Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 20 Jan 2026 11:24:28 +0100 Subject: [PATCH 15/22] Updating version --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab891c3fc2d8..a064fdfad2fb 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 = '1106-305ea6d1ba5673f870c23c78f6dbbda86af4778d' +wordpress-rs = '1106-8dad0111fb97082cc2ab4280835d4e1e3167210a' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2' From 4041ea73b1bf82b981940e8f035e63e89055b84e Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 20 Jan 2026 11:24:36 +0100 Subject: [PATCH 16/22] Calling daily aggregates --- .../todaysstat/TodaysStatsRepository.kt | 110 +++++++++++++++++- .../todaysstat/TodaysStatsViewModel.kt | 23 ++-- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt index 3b5531cc65a6..dc38c68f4203 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt @@ -10,14 +10,15 @@ import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.StatsVisitsDataValue import uniffi.wp_api.StatsVisitsParams +import uniffi.wp_api.StatsVisitsUnit import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import javax.inject.Inject import javax.inject.Named -private const val HOURLY_UNIT = "hour" private const val HOURLY_QUANTITY = 24u +private const val DAILY_QUANTITY = 1u /** * Repository for fetching stats data using the wordpress-rs API. @@ -47,6 +48,58 @@ class TodaysStatsRepository @Inject constructor( this.accessToken = accessToken } + /** + * Fetches today's aggregated stats (views, visitors, likes, comments). + * + * @param siteId The WordPress.com site ID + * @return Today's aggregated stats or error + */ + suspend fun fetchTodayAggregates(siteId: Long): TodayAggregatesResult = withContext(ioDispatcher) { + if (accessToken == null) { + appLogWrapper.e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + return@withContext TodayAggregatesResult.Error("Repository not initialized") + } + + val calendar = Calendar.getInstance() + val dateString = dateFormat.format(calendar.time) + + val params = StatsVisitsParams( + unit = StatsVisitsUnit.DAY, + quantity = DAILY_QUANTITY, + endDate = dateString, + ) + + val result = wpComApiClient.request { requestBuilder -> + requestBuilder.statsVisits().getStatsVisits( + wpComSiteId = siteId.toULong(), + params = params + ) + } + + when (result) { + is WpRequestResult.Success -> { + val response = result.response.data + val row = response.data.firstOrNull() + val aggregates = row?.let { parseDailyAggregates(it) } + if (aggregates != null) { + TodayAggregatesResult.Success(aggregates) + } else { + TodayAggregatesResult.Error("No data available") + } + } + + is WpRequestResult.WpError -> { + appLogWrapper.e(AppLog.T.STATS, "API Error fetching today aggregates: ${result.errorMessage}") + TodayAggregatesResult.Error(result.errorMessage) + } + + else -> { + appLogWrapper.e(AppLog.T.STATS, "Unknown error fetching today aggregates") + TodayAggregatesResult.Error("Unknown error") + } + } + } + /** * Fetches hourly views data for the specified date. * @@ -68,9 +121,9 @@ class TodaysStatsRepository @Inject constructor( val dateString = dateFormat.format(calendar.time) val params = StatsVisitsParams( - unit = HOURLY_UNIT, + unit = StatsVisitsUnit.HOUR, quantity = HOURLY_QUANTITY, - date = dateString, + endDate = dateString, ) val result = wpComApiClient.request { requestBuilder -> @@ -84,7 +137,7 @@ class TodaysStatsRepository @Inject constructor( is WpRequestResult.Success -> { val response = result.response.data val dataPoints = response.data.mapNotNull { row -> - parseDataRow(row) + parseHourlyDataRow(row) } HourlyViewsResult.Success(dataPoints) } @@ -102,7 +155,7 @@ class TodaysStatsRepository @Inject constructor( } @Suppress("TooGenericExceptionCaught") - private fun parseDataRow(row: Any?): HourlyViewsDataPoint? { + private fun parseHourlyDataRow(row: Any?): HourlyViewsDataPoint? { return try { val rowList = row as? List<*> ?: return null val periodValue = rowList.getOrNull(0) @@ -125,6 +178,35 @@ class TodaysStatsRepository @Inject constructor( null } } + + @Suppress("TooGenericExceptionCaught") + private fun parseDailyAggregates(row: Any?): TodayAggregates? { + return try { + val rowList = row as? List<*> ?: return null + // Response fields order: period, views, visitors, likes, reblogs, comments, posts + val viewsValue = rowList.getOrNull(1) + val visitorsValue = rowList.getOrNull(2) + val likesValue = rowList.getOrNull(3) + val commentsValue = rowList.getOrNull(5) + + TodayAggregates( + views = extractLongValue(viewsValue), + visitors = extractLongValue(visitorsValue), + likes = extractLongValue(likesValue), + comments = extractLongValue(commentsValue) + ) + } catch (e: Exception) { + appLogWrapper.w(AppLog.T.STATS, "Failed to parse daily aggregates: ${e.message}") + null + } + } + + private fun extractLongValue(value: Any?): Long { + return when (value) { + is StatsVisitsDataValue.Number -> value.v1.toLong() + else -> 0L + } + } } /** @@ -142,3 +224,21 @@ data class HourlyViewsDataPoint( val period: String, val views: Long ) + +/** + * Result wrapper for today's aggregated stats fetch operation. + */ +sealed class TodayAggregatesResult { + data class Success(val aggregates: TodayAggregates) : TodayAggregatesResult() + data class Error(val message: String) : TodayAggregatesResult() +} + +/** + * Today's aggregated stats data. + */ +data class TodayAggregates( + val views: Long, + val visitors: Long, + val likes: Long, + val comments: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 1b2638805852..39bf335c7bca 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore -import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.viewmodel.ResourceProvider import java.text.SimpleDateFormat @@ -23,7 +22,6 @@ private const val PREVIOUS_PERIOD_OFFSET_DAYS = 1 class TodaysStatsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, - private val todayInsightsStore: TodayInsightsStore, private val todaysStatsRepository: TodaysStatsRepository, private val resourceProvider: ResourceProvider ) : ViewModel() { @@ -84,7 +82,7 @@ class TodaysStatsViewModel @Inject constructor( } try { - val todayStats = fetchTodayStats(site, forced) + val todayStats = fetchTodayStats(site) val chartData = fetchChartData(site) if (todayStats != null) { @@ -110,19 +108,18 @@ class TodaysStatsViewModel @Inject constructor( } } - private suspend fun fetchTodayStats(site: SiteModel, forced: Boolean): TodayStatsData? { - val response = todayInsightsStore.fetchTodayInsights(site, forced) - return if (response.isError) { - null - } else { - response.model?.let { model -> + private suspend fun fetchTodayStats(site: SiteModel): TodayStatsData? { + val result = todaysStatsRepository.fetchTodayAggregates(site.siteId) + return when (result) { + is TodayAggregatesResult.Success -> { TodayStatsData( - views = model.views, - visitors = model.visitors, - likes = model.likes, - comments = model.comments + views = result.aggregates.views.toInt(), + visitors = result.aggregates.visitors.toInt(), + likes = result.aggregates.likes.toInt(), + comments = result.aggregates.comments.toInt() ) } + is TodayAggregatesResult.Error -> null } } From a9e8d6eb3e3f667ff7eb2fa75f0d0089df603f86 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 20 Jan 2026 15:36:11 +0100 Subject: [PATCH 17/22] Adding tests --- .../todaysstat/TodaysStatsViewModelTest.kt | 153 +++++++++++++++++- 1 file changed, 149 insertions(+), 4 deletions(-) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt index 05e16167d203..0fb09b47a611 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -7,6 +7,7 @@ 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 @@ -148,7 +149,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { viewModel.loadData(forced = true) advanceUntilIdle() - verify(todaysStatsRepository).fetchTodayAggregates(eq(TEST_SITE_ID)) + // Called twice: once during init, once during loadData(forced = true) + verify(todaysStatsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test @@ -166,7 +168,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { viewModel.onRetry() advanceUntilIdle() - verify(todaysStatsRepository).fetchTodayAggregates(eq(TEST_SITE_ID)) + // Called twice: once during init, once during onRetry + verify(todaysStatsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test @@ -314,8 +317,8 @@ class TodaysStatsViewModelTest : BaseUnitTest() { viewModel.refresh() advanceUntilIdle() - // Verify that fetchTodayAggregates was called during refresh - verify(todaysStatsRepository).fetchTodayAggregates(eq(TEST_SITE_ID)) + // Called twice: once during init, once during refresh + verify(todaysStatsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test @@ -340,6 +343,148 @@ class TodaysStatsViewModelTest : BaseUnitTest() { assertThat(viewModel.uiState.value).isInstanceOf(TodaysStatsCardUiState.Loaded::class.java) } + @Test + fun `when access token is null, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when access token is empty, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn("") + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(TodaysStatsCardUiState.Error::class.java) + assertThat((state as TodaysStatsCardUiState.Error).message).isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when loadData is called, then repository is initialized with access token`() = test { + val aggregates = createTodayAggregates() + + whenever(todaysStatsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + verify(todaysStatsRepository).init(eq(TEST_ACCESS_TOKEN)) + } + + @Test + fun `when chart data has labels, then they are formatted correctly`() = test { + val aggregates = createTodayAggregates() + + whenever(todaysStatsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + // Labels should be formatted as "2pm", "3pm" from "2024-01-16 14:00:00", "2024-01-16 15:00:00" + assertThat(state.chartData.currentPeriod).isNotEmpty() + assertThat(state.chartData.currentPeriod[0].label).isNotEmpty() + } + + @Test + fun `when only current period hourly fetch fails, then current period is empty`() = test { + val aggregates = createTodayAggregates() + + whenever(todaysStatsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + // Current period (offsetDays=0) fails + whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) + .thenReturn(HourlyViewsResult.Error("Network error")) + // Previous period (offsetDays=1) succeeds + whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + assertThat(state.chartData.currentPeriod).isEmpty() + assertThat(state.chartData.previousPeriod).hasSize(2) + } + + @Test + fun `when only previous period hourly fetch fails, then previous period is empty`() = test { + val aggregates = createTodayAggregates() + + whenever(todaysStatsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + // Current period (offsetDays=0) succeeds + whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) + .thenReturn(createHourlyViewsResult()) + // Previous period (offsetDays=1) fails + whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) + .thenReturn(HourlyViewsResult.Error("Network error")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + assertThat(state.chartData.currentPeriod).hasSize(2) + assertThat(state.chartData.previousPeriod).isEmpty() + } + + @Test + fun `when loaded state is shown, then onCardClick callback can be invoked`() = test { + val aggregates = createTodayAggregates() + + whenever(todaysStatsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(createHourlyViewsResult()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + // Verify onCardClick callback can be invoked without error + state.onCardClick() + // If we reached here, the callback is present and invocable + } + + @Test + fun `when data loads with zero values, then loaded state shows zeros`() = test { + val aggregates = TodayAggregates( + views = 0L, + visitors = 0L, + likes = 0L, + comments = 0L + ) + + whenever(todaysStatsRepository.fetchTodayAggregates(any())) + .thenReturn(TodayAggregatesResult.Success(aggregates)) + whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + .thenReturn(HourlyViewsResult.Success(emptyList())) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded + assertThat(state.views).isEqualTo(0) + assertThat(state.visitors).isEqualTo(0) + assertThat(state.likes).isEqualTo(0) + assertThat(state.comments).isEqualTo(0) + assertThat(state.chartData.currentPeriod).isEmpty() + } + private fun createTodayAggregates() = TodayAggregates( views = TEST_VIEWS.toLong(), visitors = TEST_VISITORS.toLong(), From c29f34b4aca29b3500262d751d0b020332b3a71d Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 20 Jan 2026 15:36:17 +0100 Subject: [PATCH 18/22] detekt --- .../todaysstat/TodaysStatsRepository.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt index dc38c68f4203..b1e934853acc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt @@ -20,6 +20,13 @@ import javax.inject.Named private const val HOURLY_QUANTITY = 24u private const val DAILY_QUANTITY = 1u +// Daily aggregates response field indexes +// Response fields order: period, views, visitors, likes, reblogs, comments, posts +private const val INDEX_VIEWS = 1 +private const val INDEX_VISITORS = 2 +private const val INDEX_LIKES = 3 +private const val INDEX_COMMENTS = 5 + /** * Repository for fetching stats data using the wordpress-rs API. * Handles hourly visits/views data for the Today's Stats card chart. @@ -154,7 +161,7 @@ class TodaysStatsRepository @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught", "ReturnCount") private fun parseHourlyDataRow(row: Any?): HourlyViewsDataPoint? { return try { val rowList = row as? List<*> ?: return null @@ -183,11 +190,10 @@ class TodaysStatsRepository @Inject constructor( private fun parseDailyAggregates(row: Any?): TodayAggregates? { return try { val rowList = row as? List<*> ?: return null - // Response fields order: period, views, visitors, likes, reblogs, comments, posts - val viewsValue = rowList.getOrNull(1) - val visitorsValue = rowList.getOrNull(2) - val likesValue = rowList.getOrNull(3) - val commentsValue = rowList.getOrNull(5) + val viewsValue = rowList.getOrNull(INDEX_VIEWS) + val visitorsValue = rowList.getOrNull(INDEX_VISITORS) + val likesValue = rowList.getOrNull(INDEX_LIKES) + val commentsValue = rowList.getOrNull(INDEX_COMMENTS) TodayAggregates( views = extractLongValue(viewsValue), From fdbc47a2fdea15ea4d8e8478ff5b7d02c7f1e877 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 20 Jan 2026 15:59:31 +0100 Subject: [PATCH 19/22] Rename and tests --- ...sStatsRepository.kt => StatsRepository.kt} | 2 +- .../todaysstat/TodaysStatsViewModel.kt | 8 +- .../todaysstat/StatsRepositoryTest.kt | 406 ++++++++++++++++++ .../todaysstat/TodaysStatsViewModelTest.kt | 96 ++--- 4 files changed, 459 insertions(+), 53 deletions(-) rename WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/{TodaysStatsRepository.kt => StatsRepository.kt} (99%) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepositoryTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt similarity index 99% rename from WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt rename to WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt index b1e934853acc..407b286c01f5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt @@ -31,7 +31,7 @@ private const val INDEX_COMMENTS = 5 * Repository for fetching stats data using the wordpress-rs API. * Handles hourly visits/views data for the Today's Stats card chart. */ -class TodaysStatsRepository @Inject constructor( +class StatsRepository @Inject constructor( private val wpComApiClientProvider: WpComApiClientProvider, private val appLogWrapper: AppLogWrapper, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 582aadc5497c..0abaf0553e09 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -22,7 +22,7 @@ private const val PREVIOUS_PERIOD_OFFSET_DAYS = 1 class TodaysStatsViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, - private val todaysStatsRepository: TodaysStatsRepository, + private val statsRepository: StatsRepository, private val resourceProvider: ResourceProvider ) : ViewModel() { private val _uiState = MutableStateFlow(TodaysStatsCardUiState.Loading) @@ -62,7 +62,7 @@ class TodaysStatsViewModel @Inject constructor( return } - todaysStatsRepository.init(accessToken) + statsRepository.init(accessToken) _uiState.value = TodaysStatsCardUiState.Loading viewModelScope.launch { @@ -109,7 +109,7 @@ class TodaysStatsViewModel @Inject constructor( } private suspend fun fetchTodayStats(site: SiteModel): TodayStatsData? { - val result = todaysStatsRepository.fetchTodayAggregates(site.siteId) + val result = statsRepository.fetchTodayAggregates(site.siteId) return when (result) { is TodayAggregatesResult.Success -> { TodayStatsData( @@ -137,7 +137,7 @@ class TodaysStatsViewModel @Inject constructor( site: SiteModel, offsetDays: Int ): List { - val result = todaysStatsRepository.fetchHourlyViews( + val result = statsRepository.fetchHourlyViews( siteId = site.siteId, offsetDays = offsetDays ) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepositoryTest.kt new file mode 100644 index 000000000000..84886538c241 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepositoryTest.kt @@ -0,0 +1,406 @@ +package org.wordpress.android.ui.newstats.todaysstat + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +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.mock +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.networking.restapi.WpComApiClientProvider +import org.wordpress.android.util.AppLog +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.StatsVisitsDataValue +import uniffi.wp_api.WpErrorCode + +@ExperimentalCoroutinesApi +class StatsRepositoryTest : BaseUnitTest() { + @Mock + lateinit var wpComApiClientProvider: WpComApiClientProvider + + @Mock + lateinit var wpComApiClient: WpComApiClient + + @Mock + lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + whenever(wpComApiClientProvider.getWpComApiClient(TEST_ACCESS_TOKEN)) + .thenReturn(wpComApiClient) + + repository = StatsRepository( + wpComApiClientProvider = wpComApiClientProvider, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + // region init tests + @Test + fun `init sets access token`() { + repository.init(TEST_ACCESS_TOKEN) + // If we get here without exception, the test passes + } + // endregion + + // region fetchTodayAggregates tests + @Test + fun `fetchTodayAggregates returns error when not initialized`() = runTest { + // Given - repository not initialized + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo("Repository not initialized") + verify(appLogWrapper).e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + } + + @Test + fun `fetchTodayAggregates returns success when API returns valid data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockDailyAggregatesResponse( + views = TEST_VIEWS, + visitors = TEST_VISITORS, + likes = TEST_LIKES, + comments = TEST_COMMENTS + ) + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(TEST_VIEWS) + assertThat(success.aggregates.visitors).isEqualTo(TEST_VISITORS) + assertThat(success.aggregates.likes).isEqualTo(TEST_LIKES) + assertThat(success.aggregates.comments).isEqualTo(TEST_COMMENTS) + } + + @Test + fun `fetchTodayAggregates returns error when API returns WpError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnWpError(API_ERROR_MESSAGE) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo(API_ERROR_MESSAGE) + } + + @Test + fun `fetchTodayAggregates returns error when API returns UnknownError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnUnknownError() + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo("Unknown error") + } + + @Test + fun `fetchTodayAggregates returns error when API returns empty data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockEmptyResponse() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Error::class.java) + assertThat((result as TodayAggregatesResult.Error).message).isEqualTo("No data available") + } + + @Test + fun `fetchTodayAggregates returns success with zero values when data has non-numeric values`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockDailyAggregatesResponseWithStringValues() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchTodayAggregates(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(TodayAggregatesResult.Success::class.java) + val success = result as TodayAggregatesResult.Success + assertThat(success.aggregates.views).isEqualTo(0L) + assertThat(success.aggregates.visitors).isEqualTo(0L) + assertThat(success.aggregates.likes).isEqualTo(0L) + assertThat(success.aggregates.comments).isEqualTo(0L) + } + // endregion + + // region fetchHourlyViews tests + @Test + fun `fetchHourlyViews returns error when not initialized`() = runTest { + // Given - repository not initialized + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo("Repository not initialized") + verify(appLogWrapper).e(AppLog.T.STATS, "Cannot fetch stats: repository not initialized") + } + + @Test + fun `fetchHourlyViews returns success when API returns valid data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockHourlyViewsResponse( + listOf( + HourlyDataPoint(TEST_PERIOD_1, TEST_HOURLY_VIEWS_1), + HourlyDataPoint(TEST_PERIOD_2, TEST_HOURLY_VIEWS_2) + ) + ) + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + val success = result as HourlyViewsResult.Success + assertThat(success.dataPoints).hasSize(2) + assertThat(success.dataPoints[0].period).isEqualTo(TEST_PERIOD_1) + assertThat(success.dataPoints[0].views).isEqualTo(TEST_HOURLY_VIEWS_1) + assertThat(success.dataPoints[1].period).isEqualTo(TEST_PERIOD_2) + assertThat(success.dataPoints[1].views).isEqualTo(TEST_HOURLY_VIEWS_2) + } + + @Test + fun `fetchHourlyViews returns success with empty list when API returns empty data`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockEmptyResponse() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + assertThat((result as HourlyViewsResult.Success).dataPoints).isEmpty() + } + + @Test + fun `fetchHourlyViews returns error when API returns WpError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnWpError(API_ERROR_MESSAGE) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo(API_ERROR_MESSAGE) + } + + @Test + fun `fetchHourlyViews returns error when API returns UnknownError`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + setupApiClientToReturnUnknownError() + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Error::class.java) + assertThat((result as HourlyViewsResult.Error).message).isEqualTo("Unknown error") + } + + @Test + fun `fetchHourlyViews with offsetDays parameter works correctly`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockHourlyViewsResponse( + listOf(HourlyDataPoint(TEST_PERIOD_1, TEST_HOURLY_VIEWS_1)) + ) + setupApiClientToReturnSuccess(mockResponse) + + // When - fetch yesterday's data + val result = repository.fetchHourlyViews(TEST_SITE_ID, offsetDays = 1) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + assertThat((result as HourlyViewsResult.Success).dataPoints).hasSize(1) + } + + @Test + fun `fetchHourlyViews returns zero views when views value is not a number`() = runTest { + // Given + repository.init(TEST_ACCESS_TOKEN) + + val mockResponse = createMockHourlyViewsResponseWithNonNumericViews() + setupApiClientToReturnSuccess(mockResponse) + + // When + val result = repository.fetchHourlyViews(TEST_SITE_ID) + + // Then + assertThat(result).isInstanceOf(HourlyViewsResult.Success::class.java) + val success = result as HourlyViewsResult.Success + assertThat(success.dataPoints).hasSize(1) + assertThat(success.dataPoints[0].views).isEqualTo(0L) + } + // endregion + + // region Helper methods + @Suppress("UNCHECKED_CAST") + private suspend fun setupApiClientToReturnSuccess(response: MockStatsResponse) { + val mockHeaderMap = mock() + val responseObject = uniffi.wp_api.StatsVisitsRequestGetStatsVisitsResponse( + data = response.toStatsVisitsResponse(), + headerMap = mockHeaderMap + ) + + val successResponse = WpRequestResult.Success(responseObject) + + whenever( + wpComApiClient.request(any()) + ).thenReturn(successResponse as WpRequestResult) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setupApiClientToReturnWpError(errorMessage: String) { + val errorResponse = WpRequestResult.WpError( + errorCode = WpErrorCode.Forbidden(), + errorMessage = errorMessage, + statusCode = 403.toUShort(), + response = "" + ) + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setupApiClientToReturnUnknownError() { + val errorResponse = WpRequestResult.UnknownError( + statusCode = 500.toUShort(), + response = "Internal Server Error" + ) + whenever( + wpComApiClient.request(any()) + ).thenReturn(errorResponse) + } + + private fun createMockDailyAggregatesResponse( + views: Long, + visitors: Long, + likes: Long, + comments: Long + ): MockStatsResponse { + // Response fields order: period, views, visitors, likes, reblogs, comments, posts + val row = listOf( + StatsVisitsDataValue.String("2024-01-16"), + StatsVisitsDataValue.Number(views.toULong()), + StatsVisitsDataValue.Number(visitors.toULong()), + StatsVisitsDataValue.Number(likes.toULong()), + StatsVisitsDataValue.Number(0.toULong()), // reblogs + StatsVisitsDataValue.Number(comments.toULong()), + StatsVisitsDataValue.Number(0.toULong()) // posts + ) + return MockStatsResponse(listOf(row)) + } + + private fun createMockDailyAggregatesResponseWithStringValues(): MockStatsResponse { + // Row with period but string values for metrics (should return 0 for all) + val row = listOf( + StatsVisitsDataValue.String("2024-01-16"), + StatsVisitsDataValue.String("not a number"), // views + StatsVisitsDataValue.String("not a number"), // visitors + StatsVisitsDataValue.String("not a number"), // likes + StatsVisitsDataValue.String("not a number"), // reblogs + StatsVisitsDataValue.String("not a number"), // comments + StatsVisitsDataValue.String("not a number") // posts + ) + return MockStatsResponse(listOf(row)) + } + + private fun createMockHourlyViewsResponse(dataPoints: List): MockStatsResponse { + val rows = dataPoints.map { dataPoint -> + listOf( + StatsVisitsDataValue.String(dataPoint.period), + StatsVisitsDataValue.Number(dataPoint.views.toULong()) + ) + } + return MockStatsResponse(rows) + } + + private fun createMockHourlyViewsResponseWithNonNumericViews(): MockStatsResponse { + val row = listOf( + StatsVisitsDataValue.String(TEST_PERIOD_1), + StatsVisitsDataValue.String("not a number") // views as string instead of number + ) + return MockStatsResponse(listOf(row)) + } + + private fun createMockEmptyResponse(): MockStatsResponse { + return MockStatsResponse(emptyList()) + } + + private data class HourlyDataPoint(val period: String, val views: Long) + + private data class MockStatsResponse(val data: List>) { + fun toStatsVisitsResponse(): uniffi.wp_api.StatsVisitsResponse { + return uniffi.wp_api.StatsVisitsResponse( + date = "2024-01-16", + unit = "day", + fields = listOf("period", "views", "visitors", "likes", "reblogs", "comments", "posts"), + data = data + ) + } + } + // endregion + + companion object { + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val TEST_SITE_ID = 123L + private const val TEST_VIEWS = 500L + private const val TEST_VISITORS = 100L + private const val TEST_LIKES = 50L + private const val TEST_COMMENTS = 25L + private const val TEST_PERIOD_1 = "2024-01-16 14:00:00" + private const val TEST_PERIOD_2 = "2024-01-16 15:00:00" + private const val TEST_HOURLY_VIEWS_1 = 100L + private const val TEST_HOURLY_VIEWS_2 = 150L + private const val API_ERROR_MESSAGE = "API Error" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt index 0fb09b47a611..76ffb42f4100 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -26,7 +26,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { private lateinit var accountStore: AccountStore @Mock - private lateinit var todaysStatsRepository: TodaysStatsRepository + private lateinit var statsRepository: StatsRepository @Mock private lateinit var resourceProvider: ResourceProvider @@ -55,7 +55,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { viewModel = TodaysStatsViewModel( selectedSiteRepository, accountStore, - todaysStatsRepository, + statsRepository, resourceProvider ) } @@ -81,9 +81,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { comments = TEST_COMMENTS.toLong() ) - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -101,9 +101,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when today aggregates fetch fails, then error state is emitted`() = test { - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Error("Network error")) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -118,9 +118,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when hourly views fetch fails, then chart data is empty but state is loaded`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(HourlyViewsResult.Error("Network error")) initViewModel() @@ -138,9 +138,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when loadData is called with forced true, then repository is called`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -150,16 +150,16 @@ class TodaysStatsViewModelTest : BaseUnitTest() { advanceUntilIdle() // Called twice: once during init, once during loadData(forced = true) - verify(todaysStatsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) + verify(statsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test fun `when onRetry is called, then loadData is called with forced true`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -169,16 +169,16 @@ class TodaysStatsViewModelTest : BaseUnitTest() { advanceUntilIdle() // Called twice: once during init, once during onRetry - verify(todaysStatsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) + verify(statsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test fun `when data loads, then chart data contains current and previous period data`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -196,9 +196,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when fetch hourly views is called, then repository is called for both periods`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -206,13 +206,13 @@ class TodaysStatsViewModelTest : BaseUnitTest() { // fetchHourlyViews is called twice: once for current period (offsetDays=0), // once for previous period (offsetDays=1) - verify(todaysStatsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(0)) - verify(todaysStatsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(1)) + verify(statsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(0)) + verify(statsRepository).fetchHourlyViews(eq(TEST_SITE_ID), eq(1)) } @Test fun `when exception is thrown during fetch, then error state is emitted with exception message`() = test { - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenThrow(RuntimeException("Test exception")) initViewModel() @@ -225,7 +225,7 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when exception with null message is thrown, then error state has unknown error message`() = test { - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenThrow(RuntimeException()) initViewModel() @@ -240,9 +240,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when loadData is called again, then state transitions through loading`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -269,9 +269,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { // Now set up for successful reload whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(createTodayAggregates())) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) errorState.onRetry() @@ -284,9 +284,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when refresh is called, then isRefreshing becomes true then false`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -306,9 +306,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when refresh is called, then data is fetched`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -318,16 +318,16 @@ class TodaysStatsViewModelTest : BaseUnitTest() { advanceUntilIdle() // Called twice: once during init, once during refresh - verify(todaysStatsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) + verify(statsRepository, times(2)).fetchTodayAggregates(eq(TEST_SITE_ID)) } @Test fun `when refresh is called, then state remains loaded without showing loading state`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -371,24 +371,24 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when loadData is called, then repository is initialized with access token`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() advanceUntilIdle() - verify(todaysStatsRepository).init(eq(TEST_ACCESS_TOKEN)) + verify(statsRepository).init(eq(TEST_ACCESS_TOKEN)) } @Test fun `when chart data has labels, then they are formatted correctly`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -404,13 +404,13 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when only current period hourly fetch fails, then current period is empty`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) // Current period (offsetDays=0) fails - whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) .thenReturn(HourlyViewsResult.Error("Network error")) // Previous period (offsetDays=1) succeeds - whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -425,13 +425,13 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when only previous period hourly fetch fails, then previous period is empty`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) // Current period (offsetDays=0) succeeds - whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(0))) .thenReturn(createHourlyViewsResult()) // Previous period (offsetDays=1) fails - whenever(todaysStatsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) + whenever(statsRepository.fetchHourlyViews(eq(TEST_SITE_ID), eq(1))) .thenReturn(HourlyViewsResult.Error("Network error")) initViewModel() @@ -446,9 +446,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { fun `when loaded state is shown, then onCardClick callback can be invoked`() = test { val aggregates = createTodayAggregates() - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(createHourlyViewsResult()) initViewModel() @@ -469,9 +469,9 @@ class TodaysStatsViewModelTest : BaseUnitTest() { comments = 0L ) - whenever(todaysStatsRepository.fetchTodayAggregates(any())) + whenever(statsRepository.fetchTodayAggregates(any())) .thenReturn(TodayAggregatesResult.Success(aggregates)) - whenever(todaysStatsRepository.fetchHourlyViews(any(), any())) + whenever(statsRepository.fetchHourlyViews(any(), any())) .thenReturn(HourlyViewsResult.Success(emptyList())) initViewModel() From 015fc0c692157731c913ac2e4c747979a1d512b5 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 20 Jan 2026 16:44:06 +0100 Subject: [PATCH 20/22] Using Long instead of Int for stats --- .../ui/newstats/todaysstat/TodaysStatsCard.kt | 10 +++--- .../todaysstat/TodaysStatsCardUiState.kt | 8 ++--- .../todaysstat/TodaysStatsViewModel.kt | 16 +++++----- .../todaysstat/TodaysStatsViewModelTest.kt | 32 +++++++++---------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt index f96eb93d834b..7242e6d2e1b0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCard.kt @@ -376,10 +376,10 @@ private fun StatsChart(chartData: ChartData) { @Composable private fun MetricsRow( - views: Int, - visitors: Int, - likes: Int, - comments: Int + views: Long, + visitors: Long, + likes: Long, + comments: Long ) { Row( modifier = Modifier.fillMaxWidth(), @@ -457,7 +457,7 @@ private fun SecondaryMetricItem( } } -private fun formatStatValue(value: Int): String { +private fun formatStatValue(value: Long): String { return when { value >= MILLION -> String.format(Locale.getDefault(), "%.1fM", value / MILLION.toDouble()) value >= THOUSAND -> String.format(Locale.getDefault(), "%.1fK", value / THOUSAND.toDouble()) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt index 2c138efd4044..06fb7d1dd293 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsCardUiState.kt @@ -7,10 +7,10 @@ sealed class TodaysStatsCardUiState { data object Loading : TodaysStatsCardUiState() data class Loaded( - val views: Int, - val visitors: Int, - val likes: Int, - val comments: Int, + val views: Long, + val visitors: Long, + val likes: Long, + val comments: Long, val chartData: ChartData, val onCardClick: () -> Unit ) : TodaysStatsCardUiState() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt index 0abaf0553e09..477335394f58 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModel.kt @@ -113,10 +113,10 @@ class TodaysStatsViewModel @Inject constructor( return when (result) { is TodayAggregatesResult.Success -> { TodayStatsData( - views = result.aggregates.views.toInt(), - visitors = result.aggregates.visitors.toInt(), - likes = result.aggregates.likes.toInt(), - comments = result.aggregates.comments.toInt() + views = result.aggregates.views, + visitors = result.aggregates.visitors, + likes = result.aggregates.likes, + comments = result.aggregates.comments ) } is TodayAggregatesResult.Error -> null @@ -185,9 +185,9 @@ class TodaysStatsViewModel @Inject constructor( } private data class TodayStatsData( - val views: Int, - val visitors: Int, - val likes: Int, - val comments: Int + val views: Long, + val visitors: Long, + val likes: Long, + val comments: Long ) } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt index 76ffb42f4100..6a8e4a933d53 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/todaysstat/TodaysStatsViewModelTest.kt @@ -75,10 +75,10 @@ class TodaysStatsViewModelTest : BaseUnitTest() { @Test fun `when data loads successfully, then loaded state is emitted with correct values`() = test { val aggregates = TodayAggregates( - views = TEST_VIEWS.toLong(), - visitors = TEST_VISITORS.toLong(), - likes = TEST_LIKES.toLong(), - comments = TEST_COMMENTS.toLong() + views = TEST_VIEWS, + visitors = TEST_VISITORS, + likes = TEST_LIKES, + comments = TEST_COMMENTS ) whenever(statsRepository.fetchTodayAggregates(any())) @@ -478,18 +478,18 @@ class TodaysStatsViewModelTest : BaseUnitTest() { advanceUntilIdle() val state = viewModel.uiState.value as TodaysStatsCardUiState.Loaded - assertThat(state.views).isEqualTo(0) - assertThat(state.visitors).isEqualTo(0) - assertThat(state.likes).isEqualTo(0) - assertThat(state.comments).isEqualTo(0) + assertThat(state.views).isEqualTo(0L) + assertThat(state.visitors).isEqualTo(0L) + assertThat(state.likes).isEqualTo(0L) + assertThat(state.comments).isEqualTo(0L) assertThat(state.chartData.currentPeriod).isEmpty() } private fun createTodayAggregates() = TodayAggregates( - views = TEST_VIEWS.toLong(), - visitors = TEST_VISITORS.toLong(), - likes = TEST_LIKES.toLong(), - comments = TEST_COMMENTS.toLong() + views = TEST_VIEWS, + visitors = TEST_VISITORS, + likes = TEST_LIKES, + comments = TEST_COMMENTS ) private fun createHourlyViewsResult() = HourlyViewsResult.Success( @@ -508,10 +508,10 @@ class TodaysStatsViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L private const val TEST_ACCESS_TOKEN = "test_access_token" - private const val TEST_VIEWS = 500 - private const val TEST_VISITORS = 100 - private const val TEST_LIKES = 50 - private const val TEST_COMMENTS = 25 + private const val TEST_VIEWS = 500L + private const val TEST_VISITORS = 100L + private const val TEST_LIKES = 50L + private const val TEST_COMMENTS = 25L private const val NO_SITE_SELECTED_ERROR = "No site selected" private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" private const val UNKNOWN_ERROR = "Unknown error" From abefb77c40ccf78e527e19e9f9515128ca2c69ff Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 20 Jan 2026 16:50:01 +0100 Subject: [PATCH 21/22] Other PR suggestions --- .../android/ui/newstats/todaysstat/StatsRepository.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt index 407b286c01f5..0018631bdb8f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/todaysstat/StatsRepository.kt @@ -22,10 +22,13 @@ private const val DAILY_QUANTITY = 1u // Daily aggregates response field indexes // Response fields order: period, views, visitors, likes, reblogs, comments, posts +@Suppress("unused") private const val INDEX_PERIOD = 0 private const val INDEX_VIEWS = 1 private const val INDEX_VISITORS = 2 private const val INDEX_LIKES = 3 +@Suppress("unused") private const val INDEX_REBLOGS = 4 private const val INDEX_COMMENTS = 5 +@Suppress("unused") private const val INDEX_POSTS = 6 /** * Repository for fetching stats data using the wordpress-rs API. @@ -124,6 +127,10 @@ class StatsRepository @Inject constructor( } val calendar = Calendar.getInstance() + // The API's endDate is exclusive for hourly queries, so we need to add 1 day to get + // the target day's hours. Formula: 1 (for exclusive end) - offsetDays (0=today, 1=yesterday) + // Examples: offsetDays=0 → tomorrow's date → fetches today's hours + // offsetDays=1 → today's date → fetches yesterday's hours calendar.add(Calendar.DAY_OF_YEAR, 1 - offsetDays) val dateString = dateFormat.format(calendar.time) From 6fd1f521ec51cadcf9156e98c16ba5391b1ec336 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 21 Jan 2026 09:44:28 +0100 Subject: [PATCH 22/22] Using RS trunk version --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e204922b51be..f1cedc757348 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 = '1106-8dad0111fb97082cc2ab4280835d4e1e3167210a' +wordpress-rs = 'trunk-8d78ff73bc58961adb1059ea895eb946ff29bccb' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.2'