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'