diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml
index 79ebe2b314b4..6498f342d5aa 100644
--- a/WordPress/src/main/AndroidManifest.xml
+++ b/WordPress/src/main/AndroidManifest.xml
@@ -127,6 +127,11 @@
android:theme="@style/WordPress.NoActionBar"
android:exported="false" />
+
+
{
- response.response.data.capabilities.entries.any { (key, value) ->
- key.toString().equals("edit_theme_options", ignoreCase = true) && value
- }
+ response.response.data.capabilities
+ .hasCap(UserCapability.EditThemeOptions)
}
else -> false
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt
index 1fd7bb6c5cae..97b12a6984f0 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
@@ -87,6 +87,10 @@ import org.wordpress.android.ui.newstats.clicks.ClicksViewModel
import org.wordpress.android.ui.newstats.devices.DevicesCard
import org.wordpress.android.ui.newstats.devices.DevicesCardUiState
import org.wordpress.android.ui.newstats.devices.DevicesViewModel
+import org.wordpress.android.ui.newstats.utm.UtmCard
+import org.wordpress.android.ui.newstats.utm.UtmCardUiState
+import org.wordpress.android.ui.newstats.utm.UtmDetailActivity
+import org.wordpress.android.ui.newstats.utm.UtmViewModel
import org.wordpress.android.ui.newstats.filedownloads.FileDownloadsViewModel
import org.wordpress.android.ui.newstats.locations.LocationsCardUiState
import org.wordpress.android.ui.newstats.searchterms.SearchTermsViewModel
@@ -396,6 +400,7 @@ private fun TrafficTabContent(
videoPlaysViewModel: VideoPlaysViewModel = viewModel(),
fileDownloadsViewModel: FileDownloadsViewModel = viewModel(),
devicesViewModel: DevicesViewModel = viewModel(),
+ utmViewModel: UtmViewModel = viewModel(),
newStatsViewModel: NewStatsViewModel = viewModel()
) {
val context = LocalContext.current
@@ -412,6 +417,8 @@ private fun TrafficTabContent(
val fileDownloadsUiState by fileDownloadsViewModel.uiState.collectAsState()
val devicesUiState by devicesViewModel.uiState.collectAsState()
val selectedDeviceType by devicesViewModel.selectedDeviceType.collectAsState()
+ val utmUiState by utmViewModel.uiState.collectAsState()
+ val selectedUtmCategory by utmViewModel.selectedCategory.collectAsState()
val selectedPeriod by viewsStatsViewModel.selectedPeriod.collectAsState()
val isTodaysStatsRefreshing by todaysStatsViewModel.isRefreshing.collectAsState()
val isViewsStatsRefreshing by viewsStatsViewModel.isRefreshing.collectAsState()
@@ -429,13 +436,14 @@ private fun TrafficTabContent(
val isFileDownloadsRefreshing by fileDownloadsViewModel
.isRefreshing.collectAsState()
val isDevicesRefreshing by devicesViewModel.isRefreshing.collectAsState()
+ val isUtmRefreshing by utmViewModel.isRefreshing.collectAsState()
val isRefreshing = listOf(
isTodaysStatsRefreshing, isViewsStatsRefreshing,
isMostViewedPostsRefreshing, isMostViewedReferrersRefreshing,
isLocationsRefreshing, isAuthorsRefreshing,
isClicksRefreshing, isSearchTermsRefreshing,
isVideoPlaysRefreshing, isFileDownloadsRefreshing,
- isDevicesRefreshing
+ isDevicesRefreshing, isUtmRefreshing
).any { it }
val pullToRefreshState = rememberPullToRefreshState()
@@ -486,6 +494,9 @@ private fun TrafficTabContent(
},
onDevices = {
devicesViewModel.onPeriodChanged(selectedPeriod)
+ },
+ onUtm = {
+ utmViewModel.onPeriodChanged(selectedPeriod)
}
)
}
@@ -519,7 +530,8 @@ private fun TrafficTabContent(
onSearchTerms = { searchTermsViewModel.loadData() },
onVideoPlays = { videoPlaysViewModel.loadData() },
onFileDownloads = { fileDownloadsViewModel.loadData() },
- onDevices = { devicesViewModel.loadData() }
+ onDevices = { devicesViewModel.loadData() },
+ onUtm = { utmViewModel.loadData() }
)
}
@@ -570,7 +582,8 @@ private fun TrafficTabContent(
onFileDownloads = {
fileDownloadsViewModel.refresh()
},
- onDevices = { devicesViewModel.refresh() }
+ onDevices = { devicesViewModel.refresh() },
+ onUtm = { utmViewModel.refresh() }
)
},
indicator = {
@@ -739,6 +752,43 @@ private fun TrafficTabContent(
context = context
)
)
+ StatsCardType.UTM -> UtmCard(
+ uiState = utmUiState,
+ selectedCategory = selectedUtmCategory,
+ onCategoryChanged = utmViewModel::onCategoryChanged,
+ onShowAllClick = {
+ UtmDetailActivity.start(
+ context = context,
+ category =
+ selectedUtmCategory,
+ period = selectedPeriod
+ )
+ },
+ onRetry = utmViewModel::onRetry,
+ onRemoveCard = {
+ newStatsViewModel.removeCard(cardType)
+ },
+ cardPosition = cardPosition,
+ onMoveUp = {
+ newStatsViewModel.moveCardUp(cardType)
+ },
+ onMoveToTop = {
+ newStatsViewModel.moveCardToTop(cardType)
+ },
+ onMoveDown = {
+ newStatsViewModel.moveCardDown(cardType)
+ },
+ onMoveToBottom = {
+ newStatsViewModel.moveCardToBottom(cardType)
+ },
+ onOpenWpAdmin = buildOpenWpAdminAction(
+ isAuthError = (utmUiState as?
+ UtmCardUiState.Error)
+ ?.isAuthError == true,
+ getAdminUrl = utmViewModel::getAdminUrl,
+ context = context
+ )
+ )
StatsCardType.AUTHORS -> AuthorsCard(
uiState = authorsUiState,
onShowAllClick = {
@@ -975,7 +1025,8 @@ private fun List.dispatchToVisibleCards(
onSearchTerms: () -> Unit,
onVideoPlays: () -> Unit,
onFileDownloads: () -> Unit,
- onDevices: () -> Unit
+ onDevices: () -> Unit,
+ onUtm: () -> Unit
) {
if (StatsCardType.TODAYS_STATS in this) onTodaysStats()
if (StatsCardType.VIEWS_STATS in this) onViewsStats()
@@ -992,6 +1043,7 @@ private fun List.dispatchToVisibleCards(
if (StatsCardType.VIDEO_PLAYS in this) onVideoPlays()
if (StatsCardType.FILE_DOWNLOADS in this) onFileDownloads()
if (StatsCardType.DEVICES in this) onDevices()
+ if (StatsCardType.UTM in this) onUtm()
}
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt
index ccb4b7f654bf..8421cb994ac7 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsCardType.kt
@@ -20,7 +20,8 @@ enum class StatsCardType(
SEARCH_TERMS(R.string.stats_search_terms),
VIDEO_PLAYS(R.string.stats_videos),
FILE_DOWNLOADS(R.string.stats_file_downloads),
- DEVICES(R.string.stats_devices_title);
+ DEVICES(R.string.stats_devices_title),
+ UTM(R.string.stats_utm_title);
companion object {
/**
@@ -33,7 +34,8 @@ enum class StatsCardType(
MOST_VIEWED_REFERRERS,
LOCATIONS,
AUTHORS,
- DEVICES
+ DEVICES,
+ UTM
)
}
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt
index 58c4a80f3107..00e5a697e223 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/StatsPeriod.kt
@@ -16,7 +16,23 @@ sealed class StatsPeriod(@StringRes val labelResId: Int) {
data class Custom(val startDate: LocalDate, val endDate: LocalDate) :
StatsPeriod(R.string.stats_period_custom)
+ fun toTypeString(): String = when (this) {
+ is Today -> PERIOD_TODAY
+ is Last7Days -> PERIOD_LAST_7_DAYS
+ is Last30Days -> PERIOD_LAST_30_DAYS
+ is Last6Months -> PERIOD_LAST_6_MONTHS
+ is Last12Months -> PERIOD_LAST_12_MONTHS
+ is Custom -> PERIOD_CUSTOM
+ }
+
companion object {
+ private const val PERIOD_TODAY = "today"
+ private const val PERIOD_LAST_7_DAYS = "last_7_days"
+ private const val PERIOD_LAST_30_DAYS = "last_30_days"
+ private const val PERIOD_LAST_6_MONTHS = "last_6_months"
+ private const val PERIOD_LAST_12_MONTHS = "last_12_months"
+ private const val PERIOD_CUSTOM = "custom"
+
/**
* Returns all preset periods (excluding Custom which requires dates).
*/
@@ -27,5 +43,34 @@ sealed class StatsPeriod(@StringRes val labelResId: Int) {
Last6Months,
Last12Months
)
+
+ fun fromTypeString(
+ type: String,
+ customStartEpochDay: Long? = null,
+ customEndEpochDay: Long? = null
+ ): StatsPeriod = when (type) {
+ PERIOD_TODAY -> Today
+ PERIOD_LAST_7_DAYS -> Last7Days
+ PERIOD_LAST_30_DAYS -> Last30Days
+ PERIOD_LAST_6_MONTHS -> Last6Months
+ PERIOD_LAST_12_MONTHS -> Last12Months
+ PERIOD_CUSTOM -> {
+ if (customStartEpochDay != null &&
+ customEndEpochDay != null
+ ) {
+ Custom(
+ LocalDate.ofEpochDay(
+ customStartEpochDay
+ ),
+ LocalDate.ofEpochDay(
+ customEndEpochDay
+ )
+ )
+ } else {
+ Last7Days
+ }
+ }
+ else -> Last7Days
+ }
}
}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt
index 01acb6e56302..c0c6cda7060c 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt
@@ -281,6 +281,26 @@ interface StatsDataSource {
siteId: Long,
quantity: Int = 10
): StatsEmailsSummaryDataResult
+
+ /**
+ * Fetches UTM stats for a specific site.
+ *
+ * @param siteId The WordPress.com site ID
+ * @param keys UTM key names to query
+ * @param date End date for the query (format: yyyy-MM-dd)
+ * @param days Number of days to include
+ * @param max Maximum number of items to return (0 = no limit)
+ * @param queryTopPosts Whether to include top posts
+ * @return Result containing UTM data or an error
+ */
+ suspend fun fetchUtm(
+ siteId: Long,
+ keys: List,
+ date: String,
+ days: Int,
+ max: Int = 0,
+ queryTopPosts: Boolean = true
+ ): UtmDataResult
}
/**
@@ -767,3 +787,29 @@ data class EmailSummaryItem(
val opens: Long,
val clicks: Long
)
+
+/**
+ * Result wrapper for UTM stats fetch operation.
+ */
+sealed class UtmDataResult {
+ data class Success(val data: UtmData) : UtmDataResult()
+ data class Error(
+ val errorType: StatsErrorType
+ ) : UtmDataResult()
+}
+
+/**
+ * UTM stats data from the API.
+ */
+data class UtmData(
+ val topUtmValues: Map,
+ val topPosts: Map>
+)
+
+/**
+ * A single UTM post item from the API.
+ */
+data class UtmPostData(
+ val title: String,
+ val views: Long
+)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt
index 340809b0fec2..1613250c7386 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt
@@ -40,6 +40,7 @@ import uniffi.wp_api.SubscribersByUserTypeSortField
import uniffi.wp_api.StatsEmailsSummaryParams
import uniffi.wp_api.StatsEmailsSummaryPeriod
import uniffi.wp_api.StatsEmailsSummarySortField
+import uniffi.wp_api.StatsUtmParams
import uniffi.wp_api.WpApiParamOrder
import org.wordpress.android.util.AppLog
import org.wordpress.android.util.AppLog.T
@@ -1347,6 +1348,71 @@ class StatsDataSourceImpl @Inject constructor(
}
}
+ override suspend fun fetchUtm(
+ siteId: Long,
+ keys: List,
+ date: String,
+ days: Int,
+ max: Int,
+ queryTopPosts: Boolean
+ ): UtmDataResult {
+ val key = keys.joinToString(",")
+ val params = StatsUtmParams(
+ max = max.toUInt(),
+ date = date,
+ days = days.toUInt(),
+ startDate = null,
+ queryTopPosts = queryTopPosts
+ )
+ AppLog.d(
+ T.STATS,
+ "fetchUtm - siteId=$siteId, " +
+ "key=$key, date=$date, days=$days"
+ )
+ val result = getOrCreateClient()
+ .request { requestBuilder ->
+ requestBuilder.statsUtm()
+ .getStatsUtm(
+ wpComSiteId = siteId.toULong(),
+ statsUtmKeys = key,
+ params = params
+ )
+ }
+ logResultType("fetchUtm", result)
+ return when (result) {
+ is WpRequestResult.Success -> {
+ val response = result.response.data
+ val topValues = response.topUtmValues
+ .mapValues { it.value.toLong() }
+ val topPosts = response.topPosts
+ .mapValues { entry ->
+ entry.value.map { post ->
+ UtmPostData(
+ title = post.title,
+ views = post.views.toLong()
+ )
+ }
+ }
+ AppLog.d(
+ T.STATS,
+ "fetchUtm success - " +
+ "${topValues.size} values"
+ )
+ UtmDataResult.Success(
+ UtmData(
+ topUtmValues = topValues,
+ topPosts = topPosts
+ )
+ )
+ }
+ else -> logErrorAndReturn(
+ "fetchUtm", result
+ ) {
+ UtmDataResult.Error(it)
+ }
+ }
+ }
+
companion object {
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_FORBIDDEN = 403
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt
index 8455a1628a60..6cea8670b6cd 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt
@@ -29,6 +29,7 @@ import org.wordpress.android.ui.newstats.datasource.VideoPlaysDataResult
import org.wordpress.android.ui.newstats.datasource.StatsSubscribersDataResult
import org.wordpress.android.ui.newstats.datasource.SubscribersByUserTypeDataResult
import org.wordpress.android.ui.newstats.datasource.StatsEmailsSummaryDataResult
+import org.wordpress.android.ui.newstats.datasource.UtmDataResult
import org.wordpress.android.ui.newstats.mostviewed.MostViewedDataSource
import kotlinx.coroutines.withContext
import org.wordpress.android.fluxc.utils.AppLogWrapper
@@ -1271,6 +1272,87 @@ class StatsRepository @Inject constructor(
}
}
+ /**
+ * Fetches UTM stats for a specific site and period.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ suspend fun fetchUtm(
+ siteId: Long,
+ keys: List,
+ period: StatsPeriod
+ ): UtmResult = withContext(ioDispatcher) {
+ val curRange =
+ calculateCurrentDateRange(period)
+ val curResult = statsDataSource.fetchUtm(
+ siteId, keys,
+ curRange.dateString(),
+ curRange.daysCount()
+ )
+ when (curResult) {
+ is UtmDataResult.Success -> {
+ val curValues =
+ curResult.data.topUtmValues
+ val total = curValues.values.sum()
+ UtmResult.Success(
+ items = curValues.entries
+ .sortedByDescending {
+ it.value
+ }
+ .map { (name, views) ->
+ val posts = curResult
+ .data.topPosts[name]
+ .orEmpty()
+ UtmItemData(
+ name = name,
+ views = views,
+ topPosts = posts.map {
+ UtmPostItemData(
+ it.title,
+ it.views
+ )
+ }
+ )
+ },
+ totalViews = total
+ )
+ }
+ is UtmDataResult.Error -> {
+ appLogWrapper.e(
+ AppLog.T.STATS,
+ "Error fetching UTM: " +
+ "${curResult.errorType}"
+ )
+ UtmResult.Error(
+ curResult.errorType.messageResId,
+ curResult.errorType ==
+ StatsErrorType.AUTH_ERROR
+ )
+ }
+ }
+ }
+
+ private fun StatsDateRange.dateString(): String =
+ when (this) {
+ is StatsDateRange.Preset -> date
+ is StatsDateRange.Custom -> date
+ }
+
+ private fun StatsDateRange.daysCount(): Int =
+ when (this) {
+ is StatsDateRange.Preset -> num
+ is StatsDateRange.Custom -> {
+ val start = LocalDate.parse(
+ startDate, dateFormatter
+ )
+ val end = LocalDate.parse(
+ date, dateFormatter
+ )
+ ChronoUnit.DAYS.between(start, end)
+ .toInt()
+ .coerceAtLeast(1)
+ }
+ }
+
private fun calculateChangePercent(
totalViews: Long,
previousTotalViews: Long,
@@ -2152,3 +2234,34 @@ data class SubscribersGraphDataPoint(
val date: String,
val count: Long
)
+
+/**
+ * Result wrapper for UTM stats fetch operation.
+ */
+sealed class UtmResult {
+ data class Success(
+ val items: List,
+ val totalViews: Long
+ ) : UtmResult()
+ data class Error(
+ @StringRes val messageResId: Int,
+ val isAuthError: Boolean = false
+ ) : UtmResult()
+}
+
+/**
+ * Data for a single UTM item from the repository layer.
+ */
+data class UtmItemData(
+ val name: String,
+ val views: Long,
+ val topPosts: List
+)
+
+/**
+ * Data for a single post within a UTM item.
+ */
+data class UtmPostItemData(
+ val title: String,
+ val views: Long
+)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCard.kt
new file mode 100644
index 000000000000..e9fb2f9557d6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCard.kt
@@ -0,0 +1,287 @@
+package org.wordpress.android.ui.newstats.utm
+
+import androidx.compose.foundation.clickable
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.wordpress.android.R
+import org.wordpress.android.ui.newstats.components.CardPosition
+import org.wordpress.android.ui.newstats.components.ShowAllFooter
+import org.wordpress.android.ui.newstats.components.StatsCardContainer
+import org.wordpress.android.ui.newstats.components.StatsCardEmptyContent
+import org.wordpress.android.ui.newstats.components.StatsCardErrorContent
+import org.wordpress.android.ui.newstats.components.StatsCardHeader
+import org.wordpress.android.ui.newstats.components.StatsListHeader
+import org.wordpress.android.ui.newstats.util.ShimmerBox
+
+private val CardPadding = 16.dp
+private const val LOADING_ITEM_COUNT = 4
+
+@Suppress("LongParameterList")
+@Composable
+fun UtmCard(
+ uiState: UtmCardUiState,
+ selectedCategory: UtmCategory,
+ onCategoryChanged: (UtmCategory) -> Unit,
+ onShowAllClick: () -> Unit,
+ onRetry: () -> Unit,
+ onRemoveCard: () -> Unit,
+ modifier: Modifier = Modifier,
+ cardPosition: CardPosition? = null,
+ onMoveUp: (() -> Unit)? = null,
+ onMoveToTop: (() -> Unit)? = null,
+ onMoveDown: (() -> Unit)? = null,
+ onMoveToBottom: (() -> Unit)? = null,
+ onOpenWpAdmin: (() -> Unit)? = null
+) {
+ StatsCardContainer(modifier = modifier) {
+ when (uiState) {
+ is UtmCardUiState.Loading -> LoadingContent(
+ selectedCategory = selectedCategory,
+ onCategoryChanged = onCategoryChanged,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ is UtmCardUiState.Loaded -> LoadedContent(
+ state = uiState,
+ selectedCategory = selectedCategory,
+ onCategoryChanged = onCategoryChanged,
+ onShowAllClick = onShowAllClick,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ is UtmCardUiState.Error ->
+ StatsCardErrorContent(
+ titleResId = R.string.stats_utm_title,
+ errorMessageResId = uiState.messageResId,
+ onRetry = onRetry,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom,
+ onOpenWpAdmin = onOpenWpAdmin,
+ headerExtra = {
+ UtmCategoryDropdown(
+ selected = selectedCategory,
+ onSelected = onCategoryChanged
+ )
+ }
+ )
+ }
+ }
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun LoadingContent(
+ selectedCategory: UtmCategory,
+ onCategoryChanged: (UtmCategory) -> Unit,
+ onRemoveCard: () -> Unit,
+ cardPosition: CardPosition?,
+ onMoveUp: (() -> Unit)?,
+ onMoveToTop: (() -> Unit)?,
+ onMoveDown: (() -> Unit)?,
+ onMoveToBottom: (() -> Unit)?
+) {
+ Column(modifier = Modifier.padding(CardPadding)) {
+ StatsCardHeader(
+ titleResId = R.string.stats_utm_title,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ UtmCategoryDropdown(
+ selected = selectedCategory,
+ onSelected = onCategoryChanged
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ StatsListHeader(
+ leftHeaderResId =
+ selectedCategory.labelResId
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ repeat(LOADING_ITEM_COUNT) { index ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment =
+ Alignment.CenterVertically
+ ) {
+ ShimmerBox(
+ modifier = Modifier
+ .weight(1f)
+ .height(16.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ ShimmerBox(
+ modifier = Modifier
+ .width(50.dp)
+ .height(16.dp)
+ )
+ }
+ if (index < LOADING_ITEM_COUNT - 1) {
+ Spacer(
+ modifier = Modifier.height(4.dp)
+ )
+ }
+ }
+ }
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun LoadedContent(
+ state: UtmCardUiState.Loaded,
+ selectedCategory: UtmCategory,
+ onCategoryChanged: (UtmCategory) -> Unit,
+ onShowAllClick: () -> Unit,
+ onRemoveCard: () -> Unit,
+ cardPosition: CardPosition?,
+ onMoveUp: (() -> Unit)?,
+ onMoveToTop: (() -> Unit)?,
+ onMoveDown: (() -> Unit)?,
+ onMoveToBottom: (() -> Unit)?
+) {
+ Column(modifier = Modifier.padding(CardPadding)) {
+ StatsCardHeader(
+ titleResId = R.string.stats_utm_title,
+ onRemoveCard = onRemoveCard,
+ cardPosition = cardPosition,
+ onMoveUp = onMoveUp,
+ onMoveToTop = onMoveToTop,
+ onMoveDown = onMoveDown,
+ onMoveToBottom = onMoveToBottom
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ UtmCategoryDropdown(
+ selected = selectedCategory,
+ onSelected = onCategoryChanged
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+
+ if (state.items.isEmpty()) {
+ StatsCardEmptyContent()
+ } else {
+ StatsListHeader(
+ leftHeaderResId =
+ selectedCategory.labelResId
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ state.items.forEachIndexed { index, item ->
+ val percentage =
+ if (state.maxViewsForBar > 0) {
+ item.views.toFloat() /
+ state.maxViewsForBar.toFloat()
+ } else {
+ 0f
+ }
+ UtmExpandableRow(
+ item = item,
+ percentage = percentage,
+ maxViewsForBar =
+ state.maxViewsForBar
+ )
+ if (index < state.items.lastIndex) {
+ Spacer(
+ modifier = Modifier.height(4.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+ ShowAllFooter(onClick = onShowAllClick)
+ }
+ }
+}
+
+@Composable
+private fun UtmCategoryDropdown(
+ selected: UtmCategory,
+ onSelected: (UtmCategory) -> Unit
+) {
+ var expanded by remember { mutableStateOf(false) }
+ Box {
+ Row(
+ modifier = Modifier
+ .clickable { expanded = true },
+ verticalAlignment =
+ Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(
+ selected.labelResId
+ ),
+ style = MaterialTheme.typography
+ .labelLarge,
+ color = MaterialTheme.colorScheme
+ .onSurface
+ )
+ Icon(
+ imageVector =
+ Icons.Default.ArrowDropDown,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme
+ .onSurfaceVariant
+ )
+ }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ UtmCategory.entries.forEach { category ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ stringResource(
+ category.labelResId
+ )
+ )
+ },
+ onClick = {
+ expanded = false
+ onSelected(category)
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCardUiState.kt
new file mode 100644
index 000000000000..53e89f5118f6
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCardUiState.kt
@@ -0,0 +1,108 @@
+package org.wordpress.android.ui.newstats.utm
+
+import androidx.annotation.StringRes
+import com.google.gson.JsonParseException
+import com.google.gson.JsonParser
+import org.wordpress.android.R
+
+/**
+ * Represents the available UTM category combinations
+ * for the dropdown selector.
+ */
+enum class UtmCategory(
+ @StringRes val labelResId: Int,
+ val keys: List
+) {
+ SOURCE_MEDIUM(
+ R.string.stats_utm_source_medium,
+ listOf("utm_source", "utm_medium")
+ ),
+ CAMPAIGN_SOURCE_MEDIUM(
+ R.string.stats_utm_campaign_source_medium,
+ listOf("utm_campaign", "utm_source", "utm_medium")
+ ),
+ SOURCE(
+ R.string.stats_utm_source,
+ listOf("utm_source")
+ ),
+ MEDIUM(
+ R.string.stats_utm_medium,
+ listOf("utm_medium")
+ ),
+ CAMPAIGN(
+ R.string.stats_utm_campaign,
+ listOf("utm_campaign")
+ )
+}
+
+/**
+ * UI State for the UTM stats card.
+ */
+sealed class UtmCardUiState {
+ data object Loading : UtmCardUiState()
+
+ data class Loaded(
+ val items: List,
+ val maxViewsForBar: Long,
+ val hasMoreItems: Boolean
+ ) : UtmCardUiState()
+
+ data class Error(
+ @StringRes val messageResId: Int,
+ val isAuthError: Boolean = false
+ ) : UtmCardUiState()
+}
+
+/**
+ * A single UTM value row for display in the card.
+ */
+data class UtmUiItem(
+ val title: String,
+ val views: Long,
+ val topPosts: List
+)
+
+/**
+ * A single post within a UTM value row.
+ */
+data class UtmPostUiItem(
+ val title: String,
+ val views: Long
+)
+
+/**
+ * UI State for the UTM detail screen.
+ */
+sealed class UtmDetailUiState {
+ data object Loading : UtmDetailUiState()
+
+ data class Loaded(
+ val items: List,
+ val maxViewsForBar: Long,
+ val totalViews: Long,
+ val dateRange: String,
+ @StringRes val categoryLabelResId: Int
+ ) : UtmDetailUiState()
+
+ data class Error(
+ @StringRes val messageResId: Int,
+ val isAuthError: Boolean = false
+ ) : UtmDetailUiState()
+}
+
+/**
+ * Formats a raw UTM name from the API
+ * (e.g. `["impact","affiliate"]`) into a
+ * readable slash-separated string
+ * (e.g. `impact / affiliate`).
+ */
+internal fun formatUtmName(raw: String): String {
+ if (!raw.startsWith("[")) return raw
+ return try {
+ val arr = JsonParser.parseString(raw)
+ .asJsonArray
+ arr.joinToString(" / ") { it.asString }
+ } catch (_: JsonParseException) {
+ raw
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCommonComposables.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCommonComposables.kt
new file mode 100644
index 000000000000..36977acee8b8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmCommonComposables.kt
@@ -0,0 +1,183 @@
+package org.wordpress.android.ui.newstats.utm
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.wordpress.android.ui.newstats.components.StatsItemName
+import org.wordpress.android.ui.newstats.components.StatsListRowContainer
+import org.wordpress.android.ui.newstats.util.formatStatValue
+
+/**
+ * Expandable UTM row used in both the card and detail screen.
+ *
+ * @param item The UTM item to display
+ * @param percentage Bar fill percentage (0f..1f)
+ * @param maxViewsForBar The max views value used to
+ * compute bar widths for child post rows
+ * @param position Optional 1-based position number shown
+ * in the detail screen
+ */
+@Composable
+fun UtmExpandableRow(
+ item: UtmUiItem,
+ percentage: Float,
+ maxViewsForBar: Long,
+ position: Int? = null
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val hasTopPosts = item.topPosts.isNotEmpty()
+
+ Column {
+ StatsListRowContainer(percentage = percentage) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(
+ if (hasTopPosts) {
+ Modifier.clickable {
+ expanded = !expanded
+ }
+ } else {
+ Modifier
+ }
+ )
+ .padding(
+ horizontal = 8.dp,
+ vertical = 10.dp
+ ),
+ verticalAlignment =
+ Alignment.CenterVertically
+ ) {
+ if (position != null) {
+ Text(
+ text = "$position",
+ style = MaterialTheme.typography
+ .bodySmall,
+ color = MaterialTheme
+ .colorScheme
+ .onSurfaceVariant,
+ modifier = Modifier
+ .width(24.dp)
+ )
+ }
+ StatsItemName(
+ name = item.title,
+ modifier = Modifier.weight(1f)
+ )
+ if (hasTopPosts) {
+ Icon(
+ imageVector = if (expanded) {
+ Icons.Default
+ .KeyboardArrowUp
+ } else {
+ Icons.Default
+ .KeyboardArrowDown
+ },
+ contentDescription = null,
+ modifier = Modifier
+ .size(20.dp),
+ tint = MaterialTheme
+ .colorScheme
+ .onSurfaceVariant
+ )
+ }
+ Spacer(
+ modifier = Modifier.width(4.dp)
+ )
+ Text(
+ text = formatStatValue(
+ item.views
+ ),
+ style = MaterialTheme.typography
+ .bodyMedium,
+ color = MaterialTheme.colorScheme
+ .onSurface
+ )
+ }
+ }
+
+ AnimatedVisibility(visible = expanded) {
+ Column(
+ modifier = Modifier.padding(
+ start = 24.dp
+ )
+ ) {
+ item.topPosts.forEach { post ->
+ val postPct =
+ if (maxViewsForBar > 0) {
+ post.views.toFloat() /
+ maxViewsForBar
+ .toFloat()
+ } else {
+ 0f
+ }
+ UtmPostRow(
+ post = post,
+ percentage = postPct
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun UtmPostRow(
+ post: UtmPostUiItem,
+ percentage: Float = 0f
+) {
+ StatsListRowContainer(percentage = percentage) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = 8.dp,
+ vertical = 8.dp
+ ),
+ verticalAlignment =
+ Alignment.CenterVertically
+ ) {
+ Text(
+ text = post.title,
+ style = MaterialTheme.typography
+ .bodySmall,
+ color = MaterialTheme.colorScheme
+ .onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(
+ modifier = Modifier.width(8.dp)
+ )
+ Text(
+ text = formatStatValue(post.views),
+ style = MaterialTheme.typography
+ .bodySmall,
+ color = MaterialTheme.colorScheme
+ .onSurface
+ )
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmDetailActivity.kt
new file mode 100644
index 000000000000..ff227331b0bb
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmDetailActivity.kt
@@ -0,0 +1,334 @@
+package org.wordpress.android.ui.newstats.utm
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import dagger.hilt.android.AndroidEntryPoint
+import org.wordpress.android.R
+import org.wordpress.android.ui.compose.theme.AppThemeM3
+import org.wordpress.android.ui.main.BaseAppCompatActivity
+import org.wordpress.android.ui.newstats.StatsPeriod
+import org.wordpress.android.ui.newstats.components.StatsCardEmptyContent
+import org.wordpress.android.ui.newstats.components.StatsListHeader
+import org.wordpress.android.ui.newstats.components.StatsSummaryCard
+import org.wordpress.android.ui.newstats.util.ShimmerBox
+import org.wordpress.android.ui.ActivityLauncher
+
+@AndroidEntryPoint
+class UtmDetailActivity : BaseAppCompatActivity() {
+ private val viewModel: UtmDetailViewModel
+ by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.loadData()
+
+ setContent {
+ AppThemeM3 {
+ val context = this@UtmDetailActivity
+ val uiState by viewModel.uiState
+ .collectAsState()
+ UtmDetailScreen(
+ uiState = uiState,
+ onRetry = viewModel::retry,
+ onOpenWpAdmin = {
+ viewModel.getAdminUrl()?.let {
+ ActivityLauncher
+ .openUrlExternal(
+ context, it
+ )
+ }
+ },
+ onBackPressed =
+ onBackPressedDispatcher
+ ::onBackPressed
+ )
+ }
+ }
+ }
+
+ companion object {
+ fun start(
+ context: Context,
+ category: UtmCategory,
+ period: StatsPeriod
+ ) {
+ val intent = Intent(
+ context,
+ UtmDetailActivity::class.java
+ ).apply {
+ putExtra(
+ UtmDetailViewModel
+ .EXTRA_CATEGORY_NAME,
+ category.name
+ )
+ putExtra(
+ UtmDetailViewModel
+ .EXTRA_PERIOD_TYPE,
+ period.toTypeString()
+ )
+ if (period is StatsPeriod.Custom) {
+ putExtra(
+ UtmDetailViewModel
+ .EXTRA_CUSTOM_START_DATE,
+ period.startDate.toEpochDay()
+ )
+ putExtra(
+ UtmDetailViewModel
+ .EXTRA_CUSTOM_END_DATE,
+ period.endDate.toEpochDay()
+ )
+ }
+ }
+ context.startActivity(intent)
+ }
+ }
+}
+
+private const val LOADING_ITEM_COUNT = 8
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun UtmDetailScreen(
+ uiState: UtmDetailUiState,
+ onRetry: () -> Unit,
+ onOpenWpAdmin: () -> Unit,
+ onBackPressed: () -> Unit
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(
+ R.string.stats_utm_title
+ )
+ )
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = onBackPressed
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled
+ .ArrowBack,
+ contentDescription =
+ stringResource(
+ R.string.back
+ )
+ )
+ }
+ }
+ )
+ }
+ ) { contentPadding ->
+ when (uiState) {
+ is UtmDetailUiState.Loading ->
+ DetailLoadingContent(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(horizontal = 16.dp)
+ )
+ is UtmDetailUiState.Error ->
+ DetailErrorContent(
+ messageResId =
+ uiState.messageResId,
+ onRetry = onRetry,
+ onOpenWpAdmin =
+ if (uiState.isAuthError) {
+ onOpenWpAdmin
+ } else {
+ null
+ },
+ modifier = Modifier
+ .padding(contentPadding)
+ )
+ is UtmDetailUiState.Loaded ->
+ DetailLoadedContent(
+ state = uiState,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(horizontal = 16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun DetailLoadingContent(
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(modifier = modifier) {
+ item {
+ Spacer(
+ modifier = Modifier.height(16.dp)
+ )
+ }
+ items(LOADING_ITEM_COUNT) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment =
+ Alignment.CenterVertically
+ ) {
+ ShimmerBox(
+ modifier = Modifier
+ .weight(1f)
+ .height(16.dp)
+ )
+ Spacer(
+ modifier = Modifier.width(12.dp)
+ )
+ ShimmerBox(
+ modifier = Modifier
+ .width(50.dp)
+ .height(16.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DetailLoadedContent(
+ state: UtmDetailUiState.Loaded,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(modifier = modifier) {
+ item {
+ Spacer(
+ modifier = Modifier.height(8.dp)
+ )
+ StatsSummaryCard(
+ totalViews = state.totalViews,
+ dateRange = state.dateRange
+ )
+ Spacer(
+ modifier = Modifier.height(16.dp)
+ )
+ }
+
+ if (state.items.isEmpty()) {
+ item { StatsCardEmptyContent() }
+ } else {
+ item {
+ StatsListHeader(
+ leftHeaderResId =
+ state.categoryLabelResId
+ )
+ Spacer(
+ modifier = Modifier.height(8.dp)
+ )
+ }
+
+ itemsIndexed(
+ state.items
+ ) { index, item ->
+ val percentage =
+ if (state.maxViewsForBar > 0) {
+ item.views.toFloat() /
+ state.maxViewsForBar
+ .toFloat()
+ } else {
+ 0f
+ }
+ UtmExpandableRow(
+ item = item,
+ percentage = percentage,
+ maxViewsForBar =
+ state.maxViewsForBar,
+ position = index + 1
+ )
+ if (index < state.items.lastIndex) {
+ Spacer(
+ modifier = Modifier
+ .height(4.dp)
+ )
+ }
+ }
+ }
+
+ item {
+ Spacer(
+ modifier = Modifier.height(16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun DetailErrorContent(
+ messageResId: Int,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+ onOpenWpAdmin: (() -> Unit)? = null
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment =
+ Alignment.CenterHorizontally,
+ verticalArrangement =
+ Arrangement.Center
+ ) {
+ Text(
+ text = stringResource(messageResId),
+ style = MaterialTheme.typography
+ .bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ if (onOpenWpAdmin != null) {
+ Button(onClick = onOpenWpAdmin) {
+ Text(
+ text = stringResource(
+ R.string
+ .my_site_btn_wp_admin
+ )
+ )
+ }
+ } else {
+ Button(onClick = onRetry) {
+ Text(
+ text = stringResource(
+ R.string.retry
+ )
+ )
+ }
+ }
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmDetailViewModel.kt
new file mode 100644
index 000000000000..c2ab45da6448
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmDetailViewModel.kt
@@ -0,0 +1,183 @@
+package org.wordpress.android.ui.newstats.utm
+
+import androidx.lifecycle.SavedStateHandle
+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.R
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.ui.mysite.SelectedSiteRepository
+import org.wordpress.android.ui.newstats.StatsPeriod
+import org.wordpress.android.ui.newstats.repository.StatsRepository
+import org.wordpress.android.ui.newstats.repository.UtmResult
+import org.wordpress.android.ui.newstats.util.toDateRangeString
+import org.wordpress.android.util.AppLog
+import org.wordpress.android.viewmodel.ResourceProvider
+import javax.inject.Inject
+
+@HiltViewModel
+class UtmDetailViewModel @Inject constructor(
+ private val savedStateHandle: SavedStateHandle,
+ private val selectedSiteRepository:
+ SelectedSiteRepository,
+ private val accountStore: AccountStore,
+ private val statsRepository: StatsRepository,
+ private val resourceProvider: ResourceProvider
+) : ViewModel() {
+ private val _uiState =
+ MutableStateFlow(
+ UtmDetailUiState.Loading
+ )
+ val uiState: StateFlow =
+ _uiState.asStateFlow()
+
+ private var hasLoaded = false
+
+ @Suppress("ReturnCount")
+ fun loadData() {
+ if (hasLoaded) return
+ hasLoaded = true
+
+ val site = selectedSiteRepository
+ .getSelectedSite()
+ if (site == null) {
+ _uiState.value = UtmDetailUiState.Error(
+ R.string.stats_error_no_site
+ )
+ return
+ }
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) {
+ _uiState.value = UtmDetailUiState.Error(
+ R.string.stats_error_api
+ )
+ return
+ }
+
+ val category = resolveCategory()
+ val period = resolvePeriod()
+
+ statsRepository.init(accessToken)
+ _uiState.value = UtmDetailUiState.Loading
+
+ viewModelScope.launch {
+ fetchData(site.siteId, category, period)
+ }
+ }
+
+ fun retry() {
+ hasLoaded = false
+ loadData()
+ }
+
+ fun getAdminUrl(): String? =
+ selectedSiteRepository.getSelectedSite()
+ ?.adminUrl
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun fetchData(
+ siteId: Long,
+ category: UtmCategory,
+ period: StatsPeriod
+ ) {
+ try {
+ val result = statsRepository.fetchUtm(
+ siteId, category.keys, period
+ )
+ when (result) {
+ is UtmResult.Success -> {
+ _uiState.value = buildLoadedState(
+ result, category, period
+ )
+ }
+ is UtmResult.Error -> {
+ _uiState.value =
+ UtmDetailUiState.Error(
+ result.messageResId,
+ result.isAuthError
+ )
+ }
+ }
+ } catch (e: Exception) {
+ AppLog.e(
+ AppLog.T.STATS,
+ "Error fetching UTM detail data", e
+ )
+ _uiState.value = UtmDetailUiState.Error(
+ R.string.stats_error_unknown
+ )
+ }
+ }
+
+ private fun buildLoadedState(
+ result: UtmResult.Success,
+ category: UtmCategory,
+ period: StatsPeriod
+ ): UtmDetailUiState.Loaded {
+ val items = result.items.map {
+ UtmUiItem(
+ title = formatUtmName(it.name),
+ views = it.views,
+ topPosts = it.topPosts.map { post ->
+ UtmPostUiItem(
+ post.title, post.views
+ )
+ }
+ )
+ }
+ val maxViews =
+ items.firstOrNull()?.views ?: 0L
+ return UtmDetailUiState.Loaded(
+ items = items,
+ maxViewsForBar = maxViews,
+ totalViews = result.totalViews,
+ dateRange = period.toDateRangeString(
+ resourceProvider
+ ),
+ categoryLabelResId =
+ category.labelResId
+ )
+ }
+
+ private fun resolveCategory(): UtmCategory {
+ val name = savedStateHandle
+ .get(EXTRA_CATEGORY_NAME)
+ return if (name != null) {
+ try {
+ UtmCategory.valueOf(name)
+ } catch (_: IllegalArgumentException) {
+ UtmCategory.SOURCE_MEDIUM
+ }
+ } else {
+ UtmCategory.SOURCE_MEDIUM
+ }
+ }
+
+ private fun resolvePeriod(): StatsPeriod {
+ val type = savedStateHandle
+ .get(EXTRA_PERIOD_TYPE)
+ ?: return StatsPeriod.Last7Days
+ val customStart = savedStateHandle
+ .get(EXTRA_CUSTOM_START_DATE)
+ val customEnd = savedStateHandle
+ .get(EXTRA_CUSTOM_END_DATE)
+ return StatsPeriod.fromTypeString(
+ type, customStart, customEnd
+ )
+ }
+
+ companion object {
+ const val EXTRA_CATEGORY_NAME =
+ "extra_category_name"
+ const val EXTRA_PERIOD_TYPE =
+ "extra_period_type"
+ const val EXTRA_CUSTOM_START_DATE =
+ "extra_custom_start_date"
+ const val EXTRA_CUSTOM_END_DATE =
+ "extra_custom_end_date"
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmViewModel.kt
new file mode 100644
index 000000000000..bcea230f0838
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/utm/UtmViewModel.kt
@@ -0,0 +1,284 @@
+package org.wordpress.android.ui.newstats.utm
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import org.wordpress.android.R
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.ui.mysite.SelectedSiteRepository
+import org.wordpress.android.ui.newstats.StatsPeriod
+import org.wordpress.android.ui.newstats.repository.StatsRepository
+import org.wordpress.android.ui.newstats.repository.UtmItemData
+import org.wordpress.android.ui.newstats.repository.UtmResult
+import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.util.AppLog
+import javax.inject.Inject
+import java.util.concurrent.ConcurrentHashMap
+
+private const val CARD_MAX_ITEMS = 10
+
+@HiltViewModel
+class UtmViewModel @Inject constructor(
+ private val selectedSiteRepository: SelectedSiteRepository,
+ private val accountStore: AccountStore,
+ private val statsRepository: StatsRepository,
+ private val appPrefsWrapper: AppPrefsWrapper
+) : ViewModel() {
+ private val _selectedCategory =
+ MutableStateFlow(UtmCategory.SOURCE_MEDIUM)
+ val selectedCategory: StateFlow =
+ _selectedCategory.asStateFlow()
+
+ private val _categoryStates = UtmCategory.entries
+ .associateWith {
+ MutableStateFlow(
+ UtmCardUiState.Loading
+ )
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val uiState: StateFlow =
+ _selectedCategory.flatMapLatest { cat ->
+ _categoryStates[cat]
+ ?: MutableStateFlow(
+ UtmCardUiState.Loading
+ )
+ }.stateIn(
+ viewModelScope,
+ SharingStarted.Eagerly,
+ UtmCardUiState.Loading
+ )
+
+ private val _isRefreshing = MutableStateFlow(false)
+ val isRefreshing: StateFlow =
+ _isRefreshing.asStateFlow()
+
+ private var currentPeriod: StatsPeriod =
+ StatsPeriod.Last7Days
+ private val loadingPeriods =
+ ConcurrentHashMap()
+ private val loadedPeriods =
+ ConcurrentHashMap()
+ private val fetchJobs =
+ ConcurrentHashMap()
+
+ init {
+ loadSavedCategory()
+ }
+
+ private fun loadSavedCategory() {
+ val siteId = selectedSiteRepository
+ .getSelectedSite()?.siteId ?: return
+ val saved = appPrefsWrapper
+ .getStatsUtmCategory(siteId)
+ if (saved != null) {
+ try {
+ _selectedCategory.value =
+ UtmCategory.valueOf(saved)
+ } catch (_: IllegalArgumentException) {
+ // ignore invalid saved value
+ }
+ }
+ }
+
+ fun loadData() {
+ val site = selectedSiteRepository
+ .getSelectedSite()
+ if (site == null) {
+ setCurrentCategoryState(
+ UtmCardUiState.Error(
+ R.string.stats_error_no_site
+ )
+ )
+ return
+ }
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) {
+ setCurrentCategoryState(
+ UtmCardUiState.Error(
+ R.string.stats_error_api
+ )
+ )
+ return
+ }
+ statsRepository.init(accessToken)
+ val cat = _selectedCategory.value
+ loadingPeriods[cat] = currentPeriod
+ setCurrentCategoryState(UtmCardUiState.Loading)
+ fetchJobs[cat]?.cancel()
+ fetchJobs[cat] = viewModelScope.launch {
+ try {
+ fetchForCurrentCategory(site.siteId)
+ } finally {
+ loadingPeriods.remove(cat)
+ fetchJobs.remove(cat)
+ }
+ }
+ }
+
+ fun refresh() {
+ val site = selectedSiteRepository
+ .getSelectedSite() ?: return
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) return
+ statsRepository.init(accessToken)
+ viewModelScope.launch {
+ try {
+ _isRefreshing.value = true
+ resetLoadedPeriodForCurrentCategory()
+ fetchForCurrentCategory(site.siteId)
+ } finally {
+ _isRefreshing.value = false
+ }
+ }
+ }
+
+ fun onRetry() {
+ loadData()
+ }
+
+ fun getAdminUrl(): String? =
+ selectedSiteRepository.getSelectedSite()
+ ?.adminUrl
+
+ fun getCurrentPeriod(): StatsPeriod = currentPeriod
+
+ fun onPeriodChanged(period: StatsPeriod) {
+ val cat = _selectedCategory.value
+ if (currentPeriod == period &&
+ loadingPeriods[cat] == period
+ ) return
+ if (loadedPeriods[cat] == period) return
+ currentPeriod = period
+ cancelAllFetchJobs()
+ loadedPeriods.clear()
+ loadingPeriods.clear()
+ loadData()
+ }
+
+ @Suppress("ReturnCount")
+ fun onCategoryChanged(category: UtmCategory) {
+ if (_selectedCategory.value == category) return
+ _selectedCategory.value = category
+ val siteId = selectedSiteRepository
+ .getSelectedSite()?.siteId ?: return
+ appPrefsWrapper.setStatsUtmCategory(
+ siteId, category.name
+ )
+ if (loadedPeriods[category] != currentPeriod) {
+ val accessToken = accountStore.accessToken
+ if (accessToken.isNullOrEmpty()) return
+ statsRepository.init(accessToken)
+ loadingPeriods[category] = currentPeriod
+ setCurrentCategoryState(
+ UtmCardUiState.Loading
+ )
+ fetchJobs[category]?.cancel()
+ fetchJobs[category] =
+ viewModelScope.launch {
+ try {
+ fetchForCategory(
+ category, siteId
+ )
+ } finally {
+ loadingPeriods.remove(category)
+ fetchJobs.remove(category)
+ }
+ }
+ }
+ }
+
+ private fun cancelAllFetchJobs() {
+ fetchJobs.values.forEach { it.cancel() }
+ fetchJobs.clear()
+ }
+
+ private fun setCurrentCategoryState(
+ state: UtmCardUiState
+ ) {
+ val cat = _selectedCategory.value
+ _categoryStates[cat]?.value = state
+ }
+
+ private fun resetLoadedPeriodForCurrentCategory() {
+ loadedPeriods.remove(_selectedCategory.value)
+ }
+
+ private suspend fun fetchForCurrentCategory(
+ siteId: Long
+ ) {
+ fetchForCategory(
+ _selectedCategory.value, siteId
+ )
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun fetchForCategory(
+ category: UtmCategory,
+ siteId: Long
+ ) {
+ try {
+ val result = statsRepository.fetchUtm(
+ siteId, category.keys, currentPeriod
+ )
+ when (result) {
+ is UtmResult.Success -> {
+ loadedPeriods[category] =
+ currentPeriod
+ loadingPeriods.remove(category)
+ val items = result.items
+ .map { it.toUiItem() }
+ val cardItems =
+ items.take(CARD_MAX_ITEMS)
+ val maxViews = cardItems
+ .firstOrNull()?.views ?: 0L
+ _categoryStates[category]?.value =
+ UtmCardUiState.Loaded(
+ items = cardItems,
+ maxViewsForBar = maxViews,
+ hasMoreItems =
+ items.size >
+ CARD_MAX_ITEMS
+ )
+ }
+ is UtmResult.Error -> {
+ loadingPeriods.remove(category)
+ _categoryStates[category]?.value =
+ UtmCardUiState.Error(
+ result.messageResId,
+ result.isAuthError
+ )
+ }
+ }
+ } catch (e: Exception) {
+ loadingPeriods.remove(category)
+ AppLog.e(
+ AppLog.T.STATS,
+ "Error fetching UTM data", e
+ )
+ _categoryStates[category]?.value =
+ UtmCardUiState.Error(
+ R.string.stats_error_unknown
+ )
+ }
+ }
+
+ private fun UtmItemData.toUiItem(): UtmUiItem {
+ return UtmUiItem(
+ title = formatUtmName(name),
+ views = views,
+ topPosts = topPosts.map {
+ UtmPostUiItem(it.title, it.views)
+ }
+ )
+ }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
index 41390cf09b8d..678527e156fc 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
@@ -197,6 +197,7 @@ public enum DeletablePrefKey implements PrefKey {
STATS_CARDS_CONFIGURATION_JSON,
STATS_INSIGHTS_CARDS_CONFIGURATION_JSON,
SUBSCRIBERS_CARDS_CONFIGURATION_JSON,
+ STATS_UTM_CATEGORY,
// Login flow preserved across OAuth Custom Tabs redirect
PENDING_LOGIN_FLOW,
@@ -1821,6 +1822,26 @@ private static String getSubscribersCardsConfigurationKey(long siteId) {
return DeletablePrefKey.SUBSCRIBERS_CARDS_CONFIGURATION_JSON.name() + siteId;
}
+ @Nullable
+ public static String getStatsUtmCategory(long siteId) {
+ return prefs().getString(getStatsUtmCategoryKey(siteId), null);
+ }
+
+ public static void setStatsUtmCategory(long siteId, @Nullable String category) {
+ SharedPreferences.Editor editor = prefs().edit();
+ if (category == null) {
+ editor.remove(getStatsUtmCategoryKey(siteId));
+ } else {
+ editor.putString(getStatsUtmCategoryKey(siteId), category);
+ }
+ editor.apply();
+ }
+
+ @NonNull
+ private static String getStatsUtmCategoryKey(long siteId) {
+ return DeletablePrefKey.STATS_UTM_CATEGORY.name() + siteId;
+ }
+
/**
* Returns whether network request tracking (Chucker) is enabled.
* This is a device-level preference that persists across logout/login cycles
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
index 69e5a3e361ae..5c3a6b7c93e9 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
@@ -115,6 +115,12 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra
fun setSubscribersCardsConfigurationJson(siteId: Long, json: String?) =
AppPrefs.setSubscribersCardsConfigurationJson(siteId, json)
+ fun getStatsUtmCategory(siteId: Long): String? =
+ AppPrefs.getStatsUtmCategory(siteId)
+
+ fun setStatsUtmCategory(siteId: Long, category: String?) =
+ AppPrefs.setStatsUtmCategory(siteId, category)
+
fun getAppWidgetSiteId(appWidgetId: Int) = AppPrefs.getStatsWidgetSelectedSiteId(appWidgetId)
fun setAppWidgetSiteId(siteId: Long, appWidgetId: Int) = AppPrefs.setStatsWidgetSelectedSiteId(siteId, appWidgetId)
fun removeAppWidgetSiteId(appWidgetId: Int) = AppPrefs.removeStatsWidgetSelectedSiteId(appWidgetId)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SampleUsers.kt b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SampleUsers.kt
index b823d4d9b930..b0acbf3de063 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SampleUsers.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SampleUsers.kt
@@ -1,5 +1,6 @@
package org.wordpress.android.ui.selfhostedusers
+import uniffi.wp_api.UserCapabilitiesMap
import uniffi.wp_api.UserRole
import uniffi.wp_api.UserWithEditContext
@@ -15,7 +16,7 @@ object SampleUsers {
id = 1,
username = "@sampleUser",
avatarUrls = emptyMap(),
- capabilities = emptyMap(),
+ capabilities = UserCapabilitiesMap(emptyMap()),
email = "email@example.com",
extraCapabilities = emptyMap(),
firstName = "Sample",
@@ -35,7 +36,7 @@ object SampleUsers {
id = 2,
username = "@sampleUserWithALongUserName",
avatarUrls = emptyMap(),
- capabilities = emptyMap(),
+ capabilities = UserCapabilitiesMap(emptyMap()),
description = "User description",
email = "email@example.com",
extraCapabilities = emptyMap(),
@@ -55,7 +56,7 @@ object SampleUsers {
id = 3,
username = "@sampleUser",
avatarUrls = emptyMap(),
- capabilities = emptyMap(),
+ capabilities = UserCapabilitiesMap(emptyMap()),
description = "User description",
email = "email@example.com",
extraCapabilities = emptyMap(),
diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml
index 118ca2443e7a..c87531e48d9b 100644
--- a/WordPress/src/main/res/values/strings.xml
+++ b/WordPress/src/main/res/values/strings.xml
@@ -1689,6 +1689,14 @@
Device
Percentage
+
+ UTM
+ Source / Medium
+ Campaign / Source / Medium
+ Source
+ Medium
+ Campaign
+
All-time Subscribers
Subscribers Graph
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt
index 00c32efc9962..726f285bda49 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/StatsCardsConfigurationTest.kt
@@ -28,7 +28,8 @@ class StatsCardsConfigurationTest {
StatsCardType.SEARCH_TERMS,
StatsCardType.VIDEO_PLAYS,
StatsCardType.FILE_DOWNLOADS,
- StatsCardType.DEVICES
+ StatsCardType.DEVICES,
+ StatsCardType.UTM
)
}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/utm/UtmDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/utm/UtmDetailViewModelTest.kt
new file mode 100644
index 000000000000..742ef2ac1d9f
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/utm/UtmDetailViewModelTest.kt
@@ -0,0 +1,411 @@
+package org.wordpress.android.ui.newstats.utm
+
+import androidx.lifecycle.SavedStateHandle
+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.verify
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.R
+import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.ui.mysite.SelectedSiteRepository
+import org.wordpress.android.ui.newstats.repository.StatsRepository
+import org.wordpress.android.ui.newstats.repository.UtmItemData
+import org.wordpress.android.ui.newstats.repository.UtmPostItemData
+import org.wordpress.android.ui.newstats.repository.UtmResult
+import org.wordpress.android.viewmodel.ResourceProvider
+
+@ExperimentalCoroutinesApi
+class UtmDetailViewModelTest : BaseUnitTest() {
+ @Mock
+ lateinit var selectedSiteRepository:
+ SelectedSiteRepository
+
+ @Mock
+ lateinit var accountStore: AccountStore
+
+ @Mock
+ lateinit var statsRepository: StatsRepository
+
+ @Mock
+ lateinit var resourceProvider: ResourceProvider
+
+ private val testSite = SiteModel().apply {
+ id = 1
+ siteId = TEST_SITE_ID
+ name = "Test Site"
+ adminUrl = "https://example.com/wp-admin"
+ }
+
+ @Before
+ fun setUp() {
+ whenever(
+ selectedSiteRepository.getSelectedSite()
+ ).thenReturn(testSite)
+ whenever(accountStore.accessToken)
+ .thenReturn(TEST_ACCESS_TOKEN)
+ whenever(
+ resourceProvider.getString(
+ R.string.stats_period_last_7_days
+ )
+ ).thenReturn("Last 7 days")
+ }
+
+ private fun createViewModel(
+ categoryName: String =
+ UtmCategory.SOURCE_MEDIUM.name,
+ periodType: String = "last_7_days"
+ ): UtmDetailViewModel {
+ val handle = SavedStateHandle(
+ mapOf(
+ UtmDetailViewModel
+ .EXTRA_CATEGORY_NAME
+ to categoryName,
+ UtmDetailViewModel
+ .EXTRA_PERIOD_TYPE
+ to periodType
+ )
+ )
+ return UtmDetailViewModel(
+ handle,
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ resourceProvider
+ )
+ }
+
+ // region Error states
+
+ @Test
+ fun `shows error when site is null`() = test {
+ whenever(
+ selectedSiteRepository.getSelectedSite()
+ ).thenReturn(null)
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ assertThat(state).isInstanceOf(
+ UtmDetailUiState.Error::class.java
+ )
+ assertThat(
+ (state as UtmDetailUiState.Error)
+ .messageResId
+ ).isEqualTo(R.string.stats_error_no_site)
+ }
+
+ @Test
+ fun `shows error when access token is empty`() =
+ test {
+ whenever(accountStore.accessToken)
+ .thenReturn("")
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ assertThat(state).isInstanceOf(
+ UtmDetailUiState.Error::class.java
+ )
+ assertThat(
+ (state as UtmDetailUiState.Error)
+ .messageResId
+ ).isEqualTo(R.string.stats_error_api)
+ }
+
+ @Test
+ fun `shows error on repository error`() = test {
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(
+ UtmResult.Error(
+ R.string.stats_error_api,
+ isAuthError = true
+ )
+ )
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ as UtmDetailUiState.Error
+ assertThat(state.messageResId)
+ .isEqualTo(R.string.stats_error_api)
+ assertThat(state.isAuthError).isTrue()
+ }
+
+ // endregion
+
+ // region Success states
+
+ @Test
+ fun `loaded state maps all items`() = test {
+ val items = (1..15).map {
+ createItem(
+ "item_$it",
+ views = (15 - it).toLong()
+ )
+ }
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(createSuccessResult(items))
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ as UtmDetailUiState.Loaded
+ assertThat(state.items).hasSize(15)
+ }
+
+ @Test
+ fun `loaded state includes top posts`() = test {
+ val item = createItem(
+ """["source","medium"]""",
+ topPosts = listOf(
+ UtmPostItemData("Post 1", 10L),
+ UtmPostItemData("Post 2", 5L)
+ )
+ )
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(createSuccessResult(listOf(item)))
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ as UtmDetailUiState.Loaded
+ val uiItem = state.items.first()
+ assertThat(uiItem.topPosts).hasSize(2)
+ assertThat(uiItem.topPosts[0].title)
+ .isEqualTo("Post 1")
+ assertThat(uiItem.topPosts[0].views)
+ .isEqualTo(10L)
+ }
+
+ @Test
+ fun `loaded state has correct totals`() = test {
+ val items = listOf(
+ createItem("a", views = 100L),
+ createItem("b", views = 50L)
+ )
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(
+ UtmResult.Success(
+ items = items,
+ totalViews = 150L
+ )
+ )
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ as UtmDetailUiState.Loaded
+ assertThat(state.totalViews).isEqualTo(150L)
+ assertThat(state.maxViewsForBar)
+ .isEqualTo(100L)
+ }
+
+ @Test
+ fun `loaded state formats UTM names`() = test {
+ val item = createItem(
+ """["impact","affiliate"]"""
+ )
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(createSuccessResult(listOf(item)))
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ val state = vm.uiState.value
+ as UtmDetailUiState.Loaded
+ assertThat(state.items.first().title)
+ .isEqualTo("impact / affiliate")
+ }
+
+ // endregion
+
+ // region Category and period resolution
+
+ @Test
+ fun `uses category from saved state`() = test {
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(
+ createSuccessResult(
+ listOf(createItem("test"))
+ )
+ )
+
+ val vm = createViewModel(
+ categoryName = UtmCategory.CAMPAIGN.name
+ )
+ vm.loadData()
+ advanceUntilIdle()
+
+ verify(statsRepository).fetchUtm(
+ eq(TEST_SITE_ID),
+ eq(UtmCategory.CAMPAIGN.keys),
+ any()
+ )
+ }
+
+ @Test
+ fun `defaults to SOURCE_MEDIUM for invalid category`() =
+ test {
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(
+ createSuccessResult(
+ listOf(createItem("test"))
+ )
+ )
+
+ val vm = createViewModel(
+ categoryName = "INVALID"
+ )
+ vm.loadData()
+ advanceUntilIdle()
+
+ verify(statsRepository).fetchUtm(
+ eq(TEST_SITE_ID),
+ eq(UtmCategory.SOURCE_MEDIUM.keys),
+ any()
+ )
+ }
+
+ // endregion
+
+ // region Retry and dedup
+
+ @Test
+ fun `loadData only fetches once`() = test {
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(
+ createSuccessResult(
+ listOf(createItem("test"))
+ )
+ )
+
+ val vm = createViewModel()
+ vm.loadData()
+ vm.loadData()
+ advanceUntilIdle()
+
+ verify(statsRepository).fetchUtm(
+ any(), any(), any()
+ )
+ }
+
+ @Test
+ fun `retry re-fetches after failure`() = test {
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(
+ UtmResult.Error(R.string.stats_error_api)
+ ).thenReturn(
+ createSuccessResult(
+ listOf(createItem("test"))
+ )
+ )
+
+ val vm = createViewModel()
+ vm.loadData()
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value).isInstanceOf(
+ UtmDetailUiState.Error::class.java
+ )
+
+ vm.retry()
+ advanceUntilIdle()
+
+ assertThat(vm.uiState.value).isInstanceOf(
+ UtmDetailUiState.Loaded::class.java
+ )
+ }
+
+ // endregion
+
+ // region getAdminUrl
+
+ @Test
+ fun `getAdminUrl returns site admin URL`() {
+ val vm = createViewModel()
+ assertThat(vm.getAdminUrl())
+ .isEqualTo("https://example.com/wp-admin")
+ }
+
+ @Test
+ fun `getAdminUrl returns null when no site`() {
+ whenever(
+ selectedSiteRepository.getSelectedSite()
+ ).thenReturn(null)
+
+ val vm = createViewModel()
+ assertThat(vm.getAdminUrl()).isNull()
+ }
+
+ // endregion
+
+ private fun createItem(
+ name: String,
+ views: Long = 5L,
+ topPosts: List = emptyList()
+ ) = UtmItemData(
+ name = name,
+ views = views,
+ topPosts = topPosts
+ )
+
+ private fun createSuccessResult(
+ items: List
+ ) = UtmResult.Success(
+ items = items,
+ totalViews = items.sumOf { it.views }
+ )
+
+ companion object {
+ private const val TEST_SITE_ID = 123L
+ private const val TEST_ACCESS_TOKEN =
+ "test_token"
+ }
+}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/utm/UtmViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/utm/UtmViewModelTest.kt
new file mode 100644
index 000000000000..4f8ecbc2d99c
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/utm/UtmViewModelTest.kt
@@ -0,0 +1,264 @@
+package org.wordpress.android.ui.newstats.utm
+
+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.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.R
+import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.store.AccountStore
+import org.wordpress.android.ui.mysite.SelectedSiteRepository
+import org.wordpress.android.ui.newstats.StatsPeriod
+import org.wordpress.android.ui.newstats.repository.StatsRepository
+import org.wordpress.android.ui.newstats.repository.UtmItemData
+import org.wordpress.android.ui.newstats.repository.UtmPostItemData
+import org.wordpress.android.ui.newstats.repository.UtmResult
+import org.wordpress.android.ui.prefs.AppPrefsWrapper
+
+@ExperimentalCoroutinesApi
+class UtmViewModelTest : BaseUnitTest() {
+ @Mock
+ private lateinit var selectedSiteRepository: SelectedSiteRepository
+
+ @Mock
+ private lateinit var accountStore: AccountStore
+
+ @Mock
+ private lateinit var statsRepository: StatsRepository
+
+ @Mock
+ private lateinit var appPrefsWrapper: AppPrefsWrapper
+
+ private lateinit var viewModel: UtmViewModel
+
+ private val testSite = SiteModel().apply {
+ id = 1
+ siteId = TEST_SITE_ID
+ name = "Test Site"
+ }
+
+ @Before
+ fun setUp() {
+ whenever(selectedSiteRepository.getSelectedSite())
+ .thenReturn(testSite)
+ whenever(accountStore.accessToken)
+ .thenReturn(TEST_ACCESS_TOKEN)
+ whenever(
+ appPrefsWrapper.getStatsUtmCategory(TEST_SITE_ID)
+ ).thenReturn(null)
+ }
+
+ private fun initViewModel() {
+ viewModel = UtmViewModel(
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ appPrefsWrapper
+ )
+ viewModel.onPeriodChanged(StatsPeriod.Last7Days)
+ }
+
+ // region formatUtmName
+
+ @Test
+ fun `formats array name with two values`() = test {
+ val result = createSuccessResult(
+ listOf(createItem("""["impact","affiliate"]"""))
+ )
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(result)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ as UtmCardUiState.Loaded
+ assertThat(state.items.first().title)
+ .isEqualTo("impact / affiliate")
+ }
+
+ @Test
+ fun `formats array name with three values`() = test {
+ val result = createSuccessResult(
+ listOf(
+ createItem(
+ """["campaign","source","medium"]"""
+ )
+ )
+ )
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(result)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ as UtmCardUiState.Loaded
+ assertThat(state.items.first().title)
+ .isEqualTo("campaign / source / medium")
+ }
+
+ @Test
+ fun `formats single value array name`() = test {
+ val result = createSuccessResult(
+ listOf(createItem("""["google"]"""))
+ )
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(result)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ as UtmCardUiState.Loaded
+ assertThat(state.items.first().title)
+ .isEqualTo("google")
+ }
+
+ @Test
+ fun `passes through plain string name`() = test {
+ val result = createSuccessResult(
+ listOf(createItem("google"))
+ )
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(result)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ as UtmCardUiState.Loaded
+ assertThat(state.items.first().title)
+ .isEqualTo("google")
+ }
+
+ // endregion
+
+ // region Error states
+
+ @Test
+ fun `shows error when site is null`() = test {
+ whenever(selectedSiteRepository.getSelectedSite())
+ .thenReturn(null)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertThat(state)
+ .isInstanceOf(UtmCardUiState.Error::class.java)
+ assertThat(
+ (state as UtmCardUiState.Error).messageResId
+ ).isEqualTo(R.string.stats_error_no_site)
+ }
+
+ @Test
+ fun `shows error when access token is empty`() = test {
+ whenever(accountStore.accessToken).thenReturn("")
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertThat(state)
+ .isInstanceOf(UtmCardUiState.Error::class.java)
+ assertThat(
+ (state as UtmCardUiState.Error).messageResId
+ ).isEqualTo(R.string.stats_error_api)
+ }
+
+ // endregion
+
+ // region Success states
+
+ @Test
+ fun `loaded state has items capped at 10`() = test {
+ val items = (1..15).map {
+ createItem("item_$it", views = (15 - it).toLong())
+ }
+ val result = createSuccessResult(items)
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(result)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ as UtmCardUiState.Loaded
+ assertThat(state.items).hasSize(10)
+ assertThat(state.hasMoreItems).isTrue()
+ }
+
+ @Test
+ fun `loaded state with top posts`() = test {
+ val item = createItem(
+ """["source","medium"]""",
+ topPosts = listOf(
+ UtmPostItemData("Post 1", 10L),
+ UtmPostItemData("Post 2", 5L)
+ )
+ )
+ val result = createSuccessResult(listOf(item))
+ whenever(
+ statsRepository.fetchUtm(
+ eq(TEST_SITE_ID), any(), any()
+ )
+ ).thenReturn(result)
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ as UtmCardUiState.Loaded
+ val uiItem = state.items.first()
+ assertThat(uiItem.topPosts).hasSize(2)
+ assertThat(uiItem.topPosts[0].title)
+ .isEqualTo("Post 1")
+ assertThat(uiItem.topPosts[0].views)
+ .isEqualTo(10L)
+ }
+
+ // endregion
+
+ private fun createItem(
+ name: String,
+ views: Long = 5L,
+ topPosts: List = emptyList()
+ ) = UtmItemData(
+ name = name,
+ views = views,
+ topPosts = topPosts
+ )
+
+ private fun createSuccessResult(
+ items: List
+ ) = UtmResult.Success(
+ items = items,
+ totalViews = items.sumOf { it.views }
+ )
+
+ companion object {
+ private const val TEST_SITE_ID = 123L
+ private const val TEST_ACCESS_TOKEN = "test_token"
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8c2f1be73103..1ed43014306e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -101,7 +101,7 @@ wellsql = '2.0.0'
wordpress-aztec = 'v2.1.4'
wordpress-lint = '2.2.0'
wordpress-persistent-edittext = '1.0.2'
-wordpress-rs = 'trunk-262a778ead5f163f3450d62adfac21fb32048714'
+wordpress-rs = 'trunk-0d94794142482d1b7f9395c0afef57ac991c452e'
wordpress-utils = '3.14.0'
automattic-ucrop = '2.2.11'
zendesk = '5.5.3'