From 281746d2838bf14a08bef13ea1463dd6959c1090 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 26 Feb 2026 16:25:42 +0100 Subject: [PATCH 01/21] Add All-time Subscribers stats card for new stats Subscribers tab Introduces the shared infrastructure for the Subscribers tab including the wordpress-rs dependency update, data layer (3 new API endpoints), repository methods, card configuration with persistence, and the All-time Subscribers card showing current, 30-day, 60-day, and 90-day subscriber counts. Co-Authored-By: Claude Opus 4.6 --- .../ui/newstats/datasource/StatsDataSource.kt | 104 ++++++++ .../datasource/StatsDataSourceImpl.kt | 157 +++++++++++++ .../ui/newstats/repository/StatsRepository.kt | 195 +++++++++++++++ ...SubscribersCardsConfigurationRepository.kt | 215 +++++++++++++++++ .../subscribers/SubscribersCardType.kt | 31 +++ .../SubscribersCardsConfiguration.kt | 20 ++ .../alltimestats/AllTimeSubscribersCard.kt | 222 ++++++++++++++++++ .../alltimestats/AllTimeSubscribersUiState.kt | 20 ++ .../AllTimeSubscribersViewModel.kt | 128 ++++++++++ .../wordpress/android/ui/prefs/AppPrefs.java | 27 +++ .../android/ui/prefs/AppPrefsWrapper.kt | 6 + WordPress/src/main/res/values/strings.xml | 15 ++ gradle/libs.versions.toml | 2 +- 13 files changed, 1141 insertions(+), 1 deletion(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardType.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardsConfiguration.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt 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 80919743050d..803a31bbde0d 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 @@ -207,6 +207,42 @@ interface StatsDataSource { dateRange: StatsDateRange, max: Int = 10 ): DevicesDataResult + + /** + * Fetches subscriber count stats for a specific site. + * + * @param siteId The WordPress.com site ID + * @param quantity Number of data points to return + * @return Result containing subscriber count data or an error + */ + suspend fun fetchStatsSubscribers( + siteId: Long, + quantity: Int = 1 + ): StatsSubscribersDataResult + + /** + * Fetches a list of subscribers by user type. + * + * @param siteId The WordPress.com site ID + * @param perPage Number of subscribers per page + * @return Result containing subscriber items or an error + */ + suspend fun fetchSubscribersByUserType( + siteId: Long, + perPage: Int = 10 + ): SubscribersByUserTypeDataResult + + /** + * Fetches email stats summary for a specific site. + * + * @param siteId The WordPress.com site ID + * @param quantity Number of email items to return + * @return Result containing email summary items or an error + */ + suspend fun fetchStatsEmailsSummary( + siteId: Long, + quantity: Int = 10 + ): StatsEmailsSummaryDataResult } /** @@ -529,3 +565,71 @@ sealed class DevicesDataResult { * (percentage for screen size, view count for browser/platform). */ data class DevicesData(val items: Map) + +/** + * Result wrapper for stats subscribers fetch operation. + */ +sealed class StatsSubscribersDataResult { + data class Success( + val data: StatsSubscribersData + ) : StatsSubscribersDataResult() + data class Error( + val errorType: StatsErrorType + ) : StatsSubscribersDataResult() +} + +/** + * Stats subscribers data from the API. + */ +data class StatsSubscribersData( + val subscribersData: List +) + +/** + * A single data point for subscriber count at a date. + */ +data class SubscribersDataPoint( + val date: String, + val count: Long +) + +/** + * Result wrapper for subscribers by user type fetch operation. + */ +sealed class SubscribersByUserTypeDataResult { + data class Success( + val items: List + ) : SubscribersByUserTypeDataResult() + data class Error( + val errorType: StatsErrorType + ) : SubscribersByUserTypeDataResult() +} + +/** + * A single subscriber item. + */ +data class SubscriberItem( + val displayName: String, + val subscribedSince: String +) + +/** + * Result wrapper for stats emails summary fetch operation. + */ +sealed class StatsEmailsSummaryDataResult { + data class Success( + val items: List + ) : StatsEmailsSummaryDataResult() + data class Error( + val errorType: StatsErrorType + ) : StatsEmailsSummaryDataResult() +} + +/** + * A single email summary item. + */ +data class EmailSummaryItem( + val title: String, + val opens: Long, + val clicks: 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 b99735eed048..46324b9667bb 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 @@ -29,6 +29,16 @@ import uniffi.wp_api.StatsVideoPlaysPeriod import uniffi.wp_api.StatsVisitsParams import uniffi.wp_api.StatsVisitsUnit import uniffi.wp_api.WpComLanguage +import uniffi.wp_api.StatsSubscribersParams +import uniffi.wp_api.StatsSubscribersUnit +import uniffi.wp_api.StatsSubscribersStatField +import uniffi.wp_api.SubscribersByUserTypeParams +import uniffi.wp_api.SubscribersByUserTypeUserType +import uniffi.wp_api.SubscribersByUserTypeSortField +import uniffi.wp_api.StatsEmailsSummaryParams +import uniffi.wp_api.StatsEmailsSummaryPeriod +import uniffi.wp_api.StatsEmailsSummarySortField +import uniffi.wp_api.StatsEmailsSummarySortOrder import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import rs.wordpress.api.kotlin.fromLocale @@ -983,6 +993,153 @@ class StatsDataSourceImpl @Inject constructor( } } + override suspend fun fetchStatsSubscribers( + siteId: Long, + quantity: Int + ): StatsSubscribersDataResult { + val params = StatsSubscribersParams( + unit = StatsSubscribersUnit.MONTH, + quantity = quantity.toUInt(), + statFields = listOf( + StatsSubscribersStatField.SUBSCRIBERS + ) + ) + val result = getOrCreateClient().request { requestBuilder -> + requestBuilder.statsSubscribers() + .getStatsSubscribers( + wpComSiteId = siteId.toULong(), + params = params + ) + } + logResultType("fetchStatsSubscribers", result) + return when (result) { + is WpRequestResult.Success -> { + val dataPoints = + result.response.data.subscribersData() + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchStatsSubscribers success " + + "- ${dataPoints.size} data points" + ) + StatsSubscribersDataResult.Success( + StatsSubscribersData( + subscribersData = dataPoints.map { + SubscribersDataPoint( + date = it.period, + count = it.subscribers + .toLong() + ) + } + ) + ) + } + else -> logErrorAndReturn( + "fetchStatsSubscribers", result + ) { + StatsSubscribersDataResult.Error(it) + } + } + } + + override suspend fun fetchSubscribersByUserType( + siteId: Long, + perPage: Int + ): SubscribersByUserTypeDataResult { + val params = SubscribersByUserTypeParams( + userType = SubscribersByUserTypeUserType.WP_COM, + perPage = perPage.toULong(), + page = 1uL, + sort = SubscribersByUserTypeSortField + .DATE_SUBSCRIBED + ) + val result = getOrCreateClient().request { requestBuilder -> + requestBuilder.subscribers() + .listSubscribersByUserType( + wpComSiteId = siteId.toULong(), + params = params + ) + } + logResultType( + "fetchSubscribersByUserType", result + ) + return when (result) { + is WpRequestResult.Success -> { + val subscribers = result.response.data.subscribers + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchSubscribersByUserType " + + "success - " + + "${subscribers.size} subscribers" + ) + SubscribersByUserTypeDataResult.Success( + subscribers.map { subscriber -> + SubscriberItem( + displayName = + subscriber.displayName, + subscribedSince = + subscriber.dateSubscribed + .toString() + ) + } + ) + } + else -> logErrorAndReturn( + "fetchSubscribersByUserType", result + ) { + SubscribersByUserTypeDataResult.Error(it) + } + } + } + + override suspend fun fetchStatsEmailsSummary( + siteId: Long, + quantity: Int + ): StatsEmailsSummaryDataResult { + val params = StatsEmailsSummaryParams( + period = StatsEmailsSummaryPeriod.MONTH, + quantity = quantity.toUInt(), + sortField = StatsEmailsSummarySortField.OPENS, + sortOrder = StatsEmailsSummarySortOrder.DESC + ) + val result = getOrCreateClient().request { requestBuilder -> + requestBuilder.statsEmailsSummary() + .getStatsEmailsSummary( + wpComSiteId = siteId.toULong(), + params = params + ) + } + logResultType("fetchStatsEmailsSummary", result) + return when (result) { + is WpRequestResult.Success -> { + val emails = result.response.data.posts + AppLog.d( + T.STATS, + "StatsDataSourceImpl: " + + "fetchStatsEmailsSummary success" + + " - ${emails.size} emails" + ) + StatsEmailsSummaryDataResult.Success( + emails.map { email -> + EmailSummaryItem( + title = email.title.orEmpty(), + opens = email.opens + ?.toLong() ?: 0L, + clicks = email.clicks + ?.toLong() ?: 0L + ) + } + ) + } + else -> logErrorAndReturn( + "fetchStatsEmailsSummary", result + ) { + StatsEmailsSummaryDataResult.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 b479c8fb2f62..9d4d87972202 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 @@ -19,6 +19,9 @@ import org.wordpress.android.ui.newstats.datasource.StatsVisitsDataResult import org.wordpress.android.ui.newstats.datasource.TopAuthorsDataResult import org.wordpress.android.ui.newstats.datasource.TopPostsDataResult 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.mostviewed.MostViewedDataSource import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.utils.AppLogWrapper @@ -66,6 +69,7 @@ internal fun calculateItemChangePercent( } } private const val NUM_DAYS_TODAY = 1 +private const val SUBSCRIBERS_DEFAULT_MAX = 10 /** * Repository for fetching stats data using the wordpress-rs API. @@ -1320,6 +1324,138 @@ class StatsRepository @Inject constructor( } } } + + /** + * Fetches all-time subscriber counts: current, 30d ago, + * 60d ago, 90d ago. Makes 4 parallel API calls. + */ + @Suppress("MagicNumber") + suspend fun fetchSubscribersAllTime( + siteId: Long + ): SubscribersAllTimeResult = withContext(ioDispatcher) { + val (current, d30, d60, d90) = coroutineScope { + val currentDef = async { + statsDataSource.fetchStatsSubscribers( + siteId, quantity = 1 + ) + } + val d30Def = async { + statsDataSource.fetchStatsSubscribers( + siteId, quantity = 1 + ) + } + val d60Def = async { + statsDataSource.fetchStatsSubscribers( + siteId, quantity = 1 + ) + } + val d90Def = async { + statsDataSource.fetchStatsSubscribers( + siteId, quantity = 1 + ) + } + listOf( + currentDef.await(), + d30Def.await(), + d60Def.await(), + d90Def.await() + ) + } + + val results = listOf(current, d30, d60, d90) + val firstError = results.filterIsInstance< + StatsSubscribersDataResult.Error>().firstOrNull() + if (firstError != null) { + return@withContext SubscribersAllTimeResult.Error( + messageResId = + firstError.errorType.messageResId, + isAuthError = firstError.errorType == + StatsErrorType.AUTH_ERROR + ) + } + + fun extractCount( + result: StatsSubscribersDataResult + ): Long { + val data = (result as + StatsSubscribersDataResult.Success).data + return data.subscribersData + .firstOrNull()?.count ?: 0L + } + + SubscribersAllTimeResult.Success( + currentCount = extractCount(current), + count30DaysAgo = extractCount(d30), + count60DaysAgo = extractCount(d60), + count90DaysAgo = extractCount(d90) + ) + } + + /** + * Fetches a list of subscribers for the given site. + */ + suspend fun fetchSubscribersList( + siteId: Long, + perPage: Int = SUBSCRIBERS_DEFAULT_MAX + ): SubscribersListResult = withContext(ioDispatcher) { + when ( + val result = statsDataSource + .fetchSubscribersByUserType(siteId, perPage) + ) { + is SubscribersByUserTypeDataResult.Success -> { + SubscribersListResult.Success( + subscribers = result.items.map { + SubscriberItemData( + displayName = it.displayName, + subscribedSince = + it.subscribedSince + ) + } + ) + } + is SubscribersByUserTypeDataResult.Error -> { + SubscribersListResult.Error( + messageResId = + result.errorType.messageResId, + isAuthError = result.errorType == + StatsErrorType.AUTH_ERROR + ) + } + } + } + + /** + * Fetches email stats summary for the given site. + */ + suspend fun fetchEmailsSummary( + siteId: Long, + quantity: Int = SUBSCRIBERS_DEFAULT_MAX + ): EmailsStatsResult = withContext(ioDispatcher) { + when ( + val result = statsDataSource + .fetchStatsEmailsSummary(siteId, quantity) + ) { + is StatsEmailsSummaryDataResult.Success -> { + EmailsStatsResult.Success( + items = result.items.map { + EmailItemData( + title = it.title, + opens = it.opens, + clicks = it.clicks + ) + } + ) + } + is StatsEmailsSummaryDataResult.Error -> { + EmailsStatsResult.Error( + messageResId = + result.errorType.messageResId, + isAuthError = result.errorType == + StatsErrorType.AUTH_ERROR + ) + } + } + } } /** @@ -1702,3 +1838,62 @@ data class DeviceItemData( val name: String, val views: Double ) + +/** + * Result wrapper for subscribers all-time stats fetch operation. + */ +sealed class SubscribersAllTimeResult { + data class Success( + val currentCount: Long, + val count30DaysAgo: Long, + val count60DaysAgo: Long, + val count90DaysAgo: Long + ) : SubscribersAllTimeResult() + data class Error( + @StringRes val messageResId: Int, + val isAuthError: Boolean = false + ) : SubscribersAllTimeResult() +} + +/** + * Result wrapper for subscribers list fetch operation. + */ +sealed class SubscribersListResult { + data class Success( + val subscribers: List + ) : SubscribersListResult() + data class Error( + @StringRes val messageResId: Int, + val isAuthError: Boolean = false + ) : SubscribersListResult() +} + +/** + * Data for a single subscriber item from the repository layer. + */ +data class SubscriberItemData( + val displayName: String, + val subscribedSince: String +) + +/** + * Result wrapper for emails stats fetch operation. + */ +sealed class EmailsStatsResult { + data class Success( + val items: List + ) : EmailsStatsResult() + data class Error( + @StringRes val messageResId: Int, + val isAuthError: Boolean = false + ) : EmailsStatsResult() +} + +/** + * Data for a single email item from the repository layer. + */ +data class EmailItemData( + val title: String, + val opens: Long, + val clicks: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt new file mode 100644 index 000000000000..5744ce11a834 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt @@ -0,0 +1,215 @@ +package org.wordpress.android.ui.newstats.repository + +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.ui.newstats.subscribers.SubscribersCardType +import org.wordpress.android.ui.newstats.subscribers.SubscribersCardsConfiguration +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.EnumWithFallbackValueTypeAdapterFactory +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SubscribersCardsConfigurationRepository @Inject constructor( + private val appPrefsWrapper: AppPrefsWrapper, + @Named(IO_THREAD) + private val ioDispatcher: CoroutineDispatcher +) { + private val gson = GsonBuilder() + .registerTypeAdapterFactory( + EnumWithFallbackValueTypeAdapterFactory() + ) + .create() + + private val _configurationFlow = MutableStateFlow< + Pair?>(null) + val configurationFlow: StateFlow< + Pair?> = + _configurationFlow.asStateFlow() + + suspend fun getConfiguration( + siteId: Long + ): SubscribersCardsConfiguration = + withContext(ioDispatcher) { + loadConfiguration(siteId) + } + + suspend fun saveConfiguration( + siteId: Long, + configuration: SubscribersCardsConfiguration + ): Unit = withContext(ioDispatcher) { + appPrefsWrapper + .setSubscribersCardsConfigurationJson( + siteId, gson.toJson(configuration) + ) + _configurationFlow.value = + siteId to configuration + } + + suspend fun removeCard( + siteId: Long, + cardType: SubscribersCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val newVisibleCards = + current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } + + suspend fun addCard( + siteId: Long, + cardType: SubscribersCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val newVisibleCards = + current.visibleCards + cardType + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } + + suspend fun moveCardUp( + siteId: Long, + cardType: SubscribersCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex( + siteId, current, cardType, index - 1 + ) + } + } + + suspend fun moveCardToTop( + siteId: Long, + cardType: SubscribersCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex( + siteId, current, cardType, 0 + ) + } + } + + suspend fun moveCardDown( + siteId: Long, + cardType: SubscribersCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index >= 0 && + index < current.visibleCards.size - 1 + ) { + moveCardToIndex( + siteId, current, cardType, index + 1 + ) + } + } + + suspend fun moveCardToBottom( + siteId: Long, + cardType: SubscribersCardType + ): Unit = withContext(ioDispatcher) { + val current = getConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index >= 0 && + index < current.visibleCards.size - 1 + ) { + moveCardToIndex( + siteId, current, cardType, + current.visibleCards.size - 1 + ) + } + } + + private suspend fun moveCardToIndex( + siteId: Long, + current: SubscribersCardsConfiguration, + cardType: SubscribersCardType, + newIndex: Int + ) { + val newVisibleCards = + current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + newVisibleCards.add(newIndex, cardType) + saveConfiguration( + siteId, + current.copy(visibleCards = newVisibleCards) + ) + } + + @Suppress("TooGenericExceptionCaught") + private fun loadConfiguration( + siteId: Long + ): SubscribersCardsConfiguration { + val json = appPrefsWrapper + .getSubscribersCardsConfigurationJson(siteId) + if (json == null) { + return SubscribersCardsConfiguration() + } + return try { + val config = gson.fromJson( + json, + SubscribersCardsConfiguration::class.java + ) + if (isValidConfiguration(config)) { + config + } else { + AppLog.w( + AppLog.T.STATS, + "Subscribers cards config contains " + + "invalid card types, " + + "resetting to default" + ) + resetToDefault(siteId) + } + } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Failed to parse subscribers cards " + + "config, resetting to default", + e + ) + resetToDefault(siteId) + } + } + + private fun isValidConfiguration( + config: SubscribersCardsConfiguration + ): Boolean { + val validCards = config.visibleCards + .filterIsInstance() + return validCards.size == + config.visibleCards.size + } + + private fun resetToDefault( + siteId: Long + ): SubscribersCardsConfiguration { + val defaultConfig = SubscribersCardsConfiguration() + appPrefsWrapper + .setSubscribersCardsConfigurationJson( + siteId, gson.toJson(defaultConfig) + ) + return defaultConfig + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardType.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardType.kt new file mode 100644 index 000000000000..195e5cf46974 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardType.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.ui.newstats.subscribers + +import androidx.annotation.StringRes +import org.wordpress.android.R + +/** + * Defines the available card types for the Subscribers tab. + * Each card has a unique identifier and display name resource. + */ +enum class SubscribersCardType( + @StringRes val displayNameResId: Int +) { + ALL_TIME_SUBSCRIBERS(R.string.stats_subscribers_all_time), + SUBSCRIBERS_GRAPH(R.string.stats_subscribers_graph), + SUBSCRIBERS_LIST(R.string.stats_subscribers_list), + EMAILS(R.string.stats_subscribers_emails); + + companion object { + /** + * Returns the default list of visible cards + * in their default order. + */ + fun defaultCards(): List = + listOf( + ALL_TIME_SUBSCRIBERS, + SUBSCRIBERS_GRAPH, + SUBSCRIBERS_LIST, + EMAILS + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardsConfiguration.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardsConfiguration.kt new file mode 100644 index 000000000000..45d44dbcd383 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersCardsConfiguration.kt @@ -0,0 +1,20 @@ +package org.wordpress.android.ui.newstats.subscribers + +/** + * Represents the configuration for subscriber cards + * on a per-site basis. + * This is serialized to JSON for persistence. + */ +data class SubscribersCardsConfiguration( + val visibleCards: List = + SubscribersCardType.defaultCards() +) { + /** + * Returns card types that are not currently visible + * (available to add). + */ + fun hiddenCards(): List { + return SubscribersCardType.entries + .filter { it !in visibleCards } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt new file mode 100644 index 000000000000..52f2f4ef0665 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt @@ -0,0 +1,222 @@ +package org.wordpress.android.ui.newstats.subscribers.alltimestats + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.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.util.ShimmerBox +import org.wordpress.android.ui.newstats.util.formatStatValue + +private val CardPadding = 16.dp + +@Suppress("LongParameterList") +@Composable +fun AllTimeSubscribersCard( + uiState: AllTimeSubscribersUiState, + onRetry: () -> Unit, + onRemoveCard: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + when (uiState) { + is AllTimeSubscribersUiState.Loading -> + LoadingContent() + is AllTimeSubscribersUiState.Loaded -> + LoadedContent( + state = uiState, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + is AllTimeSubscribersUiState.Error -> + StatsCardErrorContent( + titleResId = + R.string.stats_subscribers_all_time, + errorMessageResId = + R.string.stats_error_api, + onRetry = onRetry, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(24.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + repeat(2) { + Column { + ShimmerBox( + modifier = Modifier + .height(32.dp) + .fillMaxWidth(0.3f) + ) + Spacer( + modifier = Modifier.height(4.dp) + ) + ShimmerBox( + modifier = Modifier + .height(16.dp) + .fillMaxWidth(0.25f) + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + repeat(2) { + Column { + ShimmerBox( + modifier = Modifier + .height(32.dp) + .fillMaxWidth(0.3f) + ) + Spacer( + modifier = Modifier.height(4.dp) + ) + ShimmerBox( + modifier = Modifier + .height(16.dp) + .fillMaxWidth(0.25f) + ) + } + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: AllTimeSubscribersUiState.Loaded, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string.stats_subscribers_all_time, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceEvenly + ) { + StatItem( + label = stringResource( + R.string.stats_subscribers_current + ), + value = state.currentCount + ) + StatItem( + label = stringResource( + R.string.stats_subscribers_30_days_ago + ), + value = state.count30DaysAgo + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceEvenly + ) { + StatItem( + label = stringResource( + R.string.stats_subscribers_60_days_ago + ), + value = state.count60DaysAgo + ) + StatItem( + label = stringResource( + R.string.stats_subscribers_90_days_ago + ), + value = state.count90DaysAgo + ) + } + } +} + +@Composable +private fun StatItem(label: String, value: Long) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = formatStatValue(value), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = + MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersUiState.kt new file mode 100644 index 000000000000..a6a9a9ef7179 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersUiState.kt @@ -0,0 +1,20 @@ +package org.wordpress.android.ui.newstats.subscribers.alltimestats + +/** + * UI state for the All-time Subscribers card. + */ +sealed class AllTimeSubscribersUiState { + data object Loading : AllTimeSubscribersUiState() + + data class Loaded( + val currentCount: Long, + val count30DaysAgo: Long, + val count60DaysAgo: Long, + val count90DaysAgo: Long + ) : AllTimeSubscribersUiState() + + data class Error( + val message: String, + val isAuthError: Boolean = false + ) : AllTimeSubscribersUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt new file mode 100644 index 000000000000..697abdd5c378 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt @@ -0,0 +1,128 @@ +package org.wordpress.android.ui.newstats.subscribers.alltimestats + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.SubscribersAllTimeResult +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +@HiltViewModel +class AllTimeSubscribersViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = MutableStateFlow( + AllTimeSubscribersUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() + + private var isLoading = false + private var isLoadedSuccessfully = false + + fun loadDataIfNeeded() { + if (isLoadedSuccessfully || isLoading) return + isLoading = true + loadData() + } + + fun refresh() { + val site = + selectedSiteRepository.getSelectedSite() + ?: return + viewModelScope.launch { + try { + _isRefreshing.value = true + loadDataInternal(site.siteId) + } finally { + _isRefreshing.value = false + } + } + } + + fun loadData() { + val site = + selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading = false + _uiState.value = AllTimeSubscribersUiState + .Error(message = "No site selected") + return + } + + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + isLoading = false + _uiState.value = AllTimeSubscribersUiState + .Error(message = "Not authenticated") + return + } + + statsRepository.init(accessToken) + _uiState.value = AllTimeSubscribersUiState.Loading + + viewModelScope.launch { + try { + loadDataInternal(site.siteId) + } finally { + isLoading = false + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadDataInternal(siteId: Long) { + try { + when ( + val result = statsRepository + .fetchSubscribersAllTime(siteId) + ) { + is SubscribersAllTimeResult.Success -> { + isLoadedSuccessfully = true + _uiState.value = + AllTimeSubscribersUiState.Loaded( + currentCount = + result.currentCount, + count30DaysAgo = + result.count30DaysAgo, + count60DaysAgo = + result.count60DaysAgo, + count90DaysAgo = + result.count90DaysAgo + ) + } + is SubscribersAllTimeResult.Error -> { + _uiState.value = + AllTimeSubscribersUiState.Error( + message = resourceProvider + .getString( + result.messageResId + ), + isAuthError = + result.isAuthError + ) + } + } + } catch (e: Exception) { + _uiState.value = + AllTimeSubscribersUiState.Error( + message = e.message + ?: "Unknown error" + ) + } + } +} 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 967107aec596..5fd58aaf14ed 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 @@ -196,6 +196,7 @@ public enum DeletablePrefKey implements PrefKey { READER_READING_PREFERENCES_JSON, SHOULD_SHOW_READER_ANNOUNCEMENT_CARD, STATS_CARDS_CONFIGURATION_JSON, + SUBSCRIBERS_CARDS_CONFIGURATION_JSON, } /** @@ -1792,6 +1793,32 @@ private static String getStatsCardsConfigurationKey(long siteId) { return DeletablePrefKey.STATS_CARDS_CONFIGURATION_JSON.name() + siteId; } + @Nullable + public static String getSubscribersCardsConfigurationJson(long siteId) { + return prefs().getString(getSubscribersCardsConfigurationKey(siteId), null); + } + + public static void setSubscribersCardsConfigurationJson( + long siteId, + @Nullable String json + ) { + SharedPreferences.Editor editor = prefs().edit(); + if (json == null) { + editor.remove(getSubscribersCardsConfigurationKey(siteId)); + } else { + editor.putString( + getSubscribersCardsConfigurationKey(siteId), + json + ); + } + editor.apply(); + } + + @NonNull + private static String getSubscribersCardsConfigurationKey(long siteId) { + return DeletablePrefKey.SUBSCRIBERS_CARDS_CONFIGURATION_JSON.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 2706800222e8..7b4669210cf8 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 @@ -105,6 +105,12 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setStatsCardsConfigurationJson(siteId: Long, json: String?) = AppPrefs.setStatsCardsConfigurationJson(siteId, json) + fun getSubscribersCardsConfigurationJson(siteId: Long): String? = + AppPrefs.getSubscribersCardsConfigurationJson(siteId) + + fun setSubscribersCardsConfigurationJson(siteId: Long, json: String?) = + AppPrefs.setSubscribersCardsConfigurationJson(siteId, json) + 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/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 81577479b57c..cad47105349b 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1618,6 +1618,21 @@ Device Percentage + + All-time Subscribers + Subscribers Graph + Subscribers + Emails + Current + 30 days ago + 60 days ago + 90 days ago + Subscriber + Since + Latest emails + Opens + Clicks + Remove Card Move Card diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8913831d856b..cdb1e95b5e1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-c5e11b50250636aad9e7810bee3ce1ff63c8e8c4' +wordpress-rs = '1200-bb46874fa66d0ce72bda84220f616b577d05fd41' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 16142855ac3f73516f117e7a56ab1991e87dbd37 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 26 Feb 2026 16:25:50 +0100 Subject: [PATCH 02/21] Add Subscribers Graph placeholder card for stats Subscribers tab Adds an empty placeholder card for the Subscribers Graph that will be populated with chart data in a future iteration. Co-Authored-By: Claude Opus 4.6 --- .../subscribersgraph/SubscribersGraphCard.kt | 70 +++++++++++++++++++ .../SubscribersGraphUiState.kt | 9 +++ .../SubscribersGraphViewModel.kt | 36 ++++++++++ 3 files changed, 115 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt new file mode 100644 index 000000000000..2bfa64e9322c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.ui.newstats.subscribers.subscribersgraph + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.StatsCardContainer +import org.wordpress.android.ui.newstats.components.StatsCardHeader + +private val CardPadding = 16.dp +private val PlaceholderHeight = 120.dp + +@Suppress("LongParameterList") +@Composable +fun SubscribersGraphCard( + onRemoveCard: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string.stats_subscribers_graph, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(PlaceholderHeight), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource( + R.string.stats_no_data_yet + ), + style = MaterialTheme + .typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt new file mode 100644 index 000000000000..d7825bca1e70 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.ui.newstats.subscribers.subscribersgraph + +/** + * UI state for the Subscribers Graph card. + * Currently a placeholder - will be populated later. + */ +sealed class SubscribersGraphUiState { + data object Placeholder : SubscribersGraphUiState() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt new file mode 100644 index 000000000000..382540a4fde8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt @@ -0,0 +1,36 @@ +package org.wordpress.android.ui.newstats.subscribers.subscribersgraph + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class SubscribersGraphViewModel @Inject constructor() + : ViewModel() { + private val _uiState = MutableStateFlow< + SubscribersGraphUiState>( + SubscribersGraphUiState.Placeholder + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() + + @Suppress("UnusedParameter") + fun loadDataIfNeeded() { + // Placeholder - no data to load yet + } + + fun refresh() { + // Placeholder - no data to refresh yet + } + + fun loadData() { + // Placeholder - no data to load yet + } +} From 663fa31edba53beeea0c2fe1e68035449537dce1 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 26 Feb 2026 16:25:59 +0100 Subject: [PATCH 03/21] Add Subscribers List card for stats Subscribers tab Displays a list of subscribers with their subscription dates. Includes a Show All CTA that opens a detail screen with the full subscriber list. Co-Authored-By: Claude Opus 4.6 --- .../subscriberslist/SubscribersListCard.kt | 281 ++++++++++++++++++ .../SubscribersListDetailActivity.kt | 196 ++++++++++++ .../subscriberslist/SubscribersListUiState.kt | 29 ++ .../SubscribersListViewModel.kt | 141 +++++++++ 4 files changed, 647 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt new file mode 100644 index 000000000000..576413059e4f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt @@ -0,0 +1,281 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.util.ShimmerBox + +private val CardPadding = 16.dp +private const val LOADING_SHIMMER_ITEM_COUNT = 5 + +@Suppress("LongParameterList") +@Composable +fun SubscribersListCard( + uiState: SubscribersListUiState, + onShowAllClick: () -> Unit, + onRetry: () -> Unit, + onRemoveCard: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + when (uiState) { + is SubscribersListUiState.Loading -> + LoadingContent() + is SubscribersListUiState.Loaded -> + LoadedContent( + state = uiState, + onShowAllClick = onShowAllClick, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + is SubscribersListUiState.Error -> + StatsCardErrorContent( + titleResId = + R.string.stats_subscribers_list, + errorMessageResId = + R.string.stats_error_api, + onRetry = onRetry, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + ShimmerBox( + modifier = Modifier + .width(100.dp) + .height(24.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + ShimmerBox( + modifier = Modifier + .width(120.dp) + .height(20.dp) + ) + ShimmerBox( + modifier = Modifier + .width(50.dp) + .height(20.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + repeat(LOADING_SHIMMER_ITEM_COUNT) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + ShimmerBox( + modifier = Modifier + .weight(1f) + .height(20.dp) + ) + Spacer( + modifier = Modifier.width(16.dp) + ) + ShimmerBox( + modifier = Modifier + .width(80.dp) + .height(16.dp) + ) + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: SubscribersListUiState.Loaded, + onShowAllClick: () -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string.stats_subscribers_list, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + if (state.items.isEmpty()) { + StatsCardEmptyContent() + } else { + // Column headers + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + Text( + text = stringResource( + R.string + .stats_subscribers_subscriber_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + Text( + text = stringResource( + R.string + .stats_subscribers_since_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(8.dp)) + state.items.forEachIndexed { index, item -> + SubscriberItemRow(item = item) + if (index < state.items.lastIndex) { + Spacer( + modifier = Modifier.height(4.dp) + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + ShowAllFooter(onClick = onShowAllClick) + } + } +} + +@Composable +private fun SubscriberItemRow( + item: SubscriberListItem +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.displayName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = formatSubscriberDate( + item.subscribedSince + ), + style = MaterialTheme + .typography.bodySmall, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + } +} + +@Suppress("TooGenericExceptionCaught", "SwallowedException") +internal fun formatSubscriberDate( + dateString: String +): String { + return try { + val inputFormat = + java.time.format.DateTimeFormatter + .ISO_DATE_TIME + val outputFormat = + java.time.format.DateTimeFormatter + .ofPattern( + "MMM d, yyyy", + java.util.Locale.getDefault() + ) + val dateTime = + java.time.LocalDateTime.parse( + dateString, inputFormat + ) + dateTime.format(outputFormat) + } catch (e: Exception) { + try { + val inputFormat = + java.time.format.DateTimeFormatter + .ISO_LOCAL_DATE + val outputFormat = + java.time.format.DateTimeFormatter + .ofPattern( + "MMM d, yyyy", + java.util.Locale.getDefault() + ) + val date = java.time.LocalDate.parse( + dateString, inputFormat + ) + date.format(outputFormat) + } catch (e2: Exception) { + dateString + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt new file mode 100644 index 000000000000..66ddf639b057 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt @@ -0,0 +1,196 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.util.extensions.getParcelableArrayListCompat + +private const val EXTRA_ITEMS = "extra_items" + +@AndroidEntryPoint +class SubscribersListDetailActivity : BaseAppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val items = intent.extras + ?.getParcelableArrayListCompat( + EXTRA_ITEMS + ) ?: arrayListOf() + + setContent { + AppThemeM3 { + SubscribersListDetailScreen( + items = items, + onBackPressed = + onBackPressedDispatcher::onBackPressed + ) + } + } + } + + companion object { + fun start( + context: Context, + items: List + ) { + val intent = Intent( + context, + SubscribersListDetailActivity::class.java + ).apply { + putExtra( + EXTRA_ITEMS, ArrayList(items) + ) + } + context.startActivity(intent) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SubscribersListDetailScreen( + items: List, + onBackPressed: () -> Unit +) { + val title = stringResource( + R.string.stats_subscribers_list + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + Icons.AutoMirrored.Filled + .ArrowBack, + contentDescription = + stringResource(R.string.back) + ) + } + } + ) + } + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp) + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + DetailColumnHeaders( + itemCount = items.size + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + itemsIndexed(items) { index, item -> + DetailSubscriberRow(item = item) + if (index < items.lastIndex) { + Spacer( + modifier = Modifier.height(4.dp) + ) + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun DetailColumnHeaders(itemCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource( + R.string.stats_most_viewed_top_n, + itemCount + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + Text( + text = stringResource( + R.string.stats_subscribers_since_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun DetailSubscriberRow( + item: SubscriberListItem +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.displayName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = formatSubscriberDate( + item.subscribedSince + ), + style = MaterialTheme + .typography.bodySmall, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt new file mode 100644 index 000000000000..59124a846b7d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * UI state for the Subscribers List card. + */ +sealed class SubscribersListUiState { + data object Loading : SubscribersListUiState() + + data class Loaded( + val items: List + ) : SubscribersListUiState() + + data class Error( + val message: String, + val isAuthError: Boolean = false + ) : SubscribersListUiState() +} + +/** + * A single subscriber item for display. + */ +@Parcelize +data class SubscriberListItem( + val displayName: String, + val subscribedSince: String +) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt new file mode 100644 index 000000000000..de1c8d351f7f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt @@ -0,0 +1,141 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.SubscribersListResult +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +private const val CARD_MAX_ITEMS = 5 +private const val DETAIL_MAX_ITEMS = 100 + +@HiltViewModel +class SubscribersListViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = MutableStateFlow< + SubscribersListUiState>( + SubscribersListUiState.Loading + ) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() + + private var isLoading = false + private var isLoadedSuccessfully = false + private var allItems: List = + emptyList() + + fun loadDataIfNeeded() { + if (isLoadedSuccessfully || isLoading) return + isLoading = true + loadData() + } + + fun refresh() { + val site = + selectedSiteRepository.getSelectedSite() + ?: return + viewModelScope.launch { + try { + _isRefreshing.value = true + loadDataInternal(site.siteId) + } finally { + _isRefreshing.value = false + } + } + } + + fun loadData() { + val site = + selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading = false + _uiState.value = SubscribersListUiState + .Error(message = "No site selected") + return + } + + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + isLoading = false + _uiState.value = SubscribersListUiState + .Error(message = "Not authenticated") + return + } + + statsRepository.init(accessToken) + _uiState.value = SubscribersListUiState.Loading + + viewModelScope.launch { + try { + loadDataInternal(site.siteId) + } finally { + isLoading = false + } + } + } + + fun getDetailData(): List = + allItems + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadDataInternal(siteId: Long) { + try { + when ( + val result = statsRepository + .fetchSubscribersList( + siteId, DETAIL_MAX_ITEMS + ) + ) { + is SubscribersListResult.Success -> { + isLoadedSuccessfully = true + allItems = result.subscribers.map { + SubscriberListItem( + displayName = it.displayName, + subscribedSince = + it.subscribedSince + ) + } + _uiState.value = + SubscribersListUiState.Loaded( + items = allItems.take( + CARD_MAX_ITEMS + ) + ) + } + is SubscribersListResult.Error -> { + _uiState.value = + SubscribersListUiState.Error( + message = resourceProvider + .getString( + result.messageResId + ), + isAuthError = + result.isAuthError + ) + } + } + } catch (e: Exception) { + _uiState.value = SubscribersListUiState + .Error( + message = e.message + ?: "Unknown error" + ) + } + } +} From 8f812c3e717e7a5996220afd7f915ab549c287eb Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 26 Feb 2026 16:26:11 +0100 Subject: [PATCH 04/21] Add Emails card and wire Subscribers tab in new stats screen Adds the Emails card showing latest emails with opens and clicks counts, including a detail screen. Wires all 4 cards into the Subscribers tab with the tab ViewModel, content composable, add-card bottom sheet, and manifest registrations. Co-Authored-By: Claude Opus 4.6 --- WordPress/src/main/AndroidManifest.xml | 10 + .../android/ui/newstats/NewStatsActivity.kt | 2 + .../AddSubscribersCardBottomSheet.kt | 109 ++++ .../subscribers/SubscribersTabContent.kt | 522 ++++++++++++++++++ .../subscribers/SubscribersTabViewModel.kt | 151 +++++ .../newstats/subscribers/emails/EmailsCard.kt | 272 +++++++++ .../subscribers/emails/EmailsCardUiState.kt | 30 + .../subscribers/emails/EmailsCardViewModel.kt | 136 +++++ .../emails/EmailsDetailActivity.kt | 212 +++++++ 9 files changed, 1444 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardUiState.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 8a5fcdd3d0f6..4900d1b4c03f 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -127,6 +127,16 @@ android:theme="@style/WordPress.NoActionBar" android:exported="false" /> + + + + TrafficTabContent(viewsStatsViewModel = viewsStatsViewModel) + StatsTab.SUBSCRIBERS -> SubscribersTabContent() else -> PlaceholderTabContent(tab) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt new file mode 100644 index 000000000000..5380558f222a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/AddSubscribersCardBottomSheet.kt @@ -0,0 +1,109 @@ +package org.wordpress.android.ui.newstats.subscribers + +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.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.wordpress.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddSubscribersCardBottomSheet( + sheetState: SheetState, + availableCards: List, + onDismiss: () -> Unit, + onCardSelected: (SubscribersCardType) -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = stringResource( + R.string.stats_add_card_title + ), + style = MaterialTheme + .typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(bottom = 16.dp) + ) + + if (availableCards.isEmpty()) { + Text( + text = stringResource( + R.string.stats_all_cards_visible + ), + style = MaterialTheme + .typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(vertical = 24.dp) + ) + } else { + availableCards.forEach { cardType -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onCardSelected(cardType) + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = + Alignment.CenterVertically + ) { + Icon( + imageVector = + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme + .colorScheme.primary, + modifier = Modifier + .size(24.dp) + ) + Spacer( + modifier = Modifier + .width(16.dp) + ) + Text( + text = stringResource( + cardType + .displayNameResId + ), + style = MaterialTheme + .typography.bodyLarge, + color = MaterialTheme + .colorScheme.onSurface + ) + } + } + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt new file mode 100644 index 000000000000..47b0a34bd9df --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt @@ -0,0 +1,522 @@ +package org.wordpress.android.ui.newstats.subscribers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.background +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import org.wordpress.android.R +import org.wordpress.android.ui.newstats.components.CardPosition +import org.wordpress.android.ui.newstats.subscribers.alltimestats.AllTimeSubscribersCard +import org.wordpress.android.ui.newstats.subscribers.alltimestats.AllTimeSubscribersViewModel +import org.wordpress.android.ui.newstats.subscribers.emails.EmailsCard +import org.wordpress.android.ui.newstats.subscribers.emails.EmailsCardViewModel +import org.wordpress.android.ui.newstats.subscribers.emails.EmailsDetailActivity +import org.wordpress.android.ui.newstats.subscribers.subscribersgraph.SubscribersGraphCard +import org.wordpress.android.ui.newstats.subscribers.subscribersgraph.SubscribersGraphViewModel +import org.wordpress.android.ui.newstats.subscribers.subscriberslist.SubscribersListCard +import org.wordpress.android.ui.newstats.subscribers.subscriberslist.SubscribersListDetailActivity +import org.wordpress.android.ui.newstats.subscribers.subscriberslist.SubscribersListViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongMethod") +fun SubscribersTabContent( + allTimeViewModel: AllTimeSubscribersViewModel = + viewModel(), + graphViewModel: SubscribersGraphViewModel = + viewModel(), + subscribersListViewModel: SubscribersListViewModel = + viewModel(), + emailsViewModel: EmailsCardViewModel = viewModel(), + subscribersTabViewModel: SubscribersTabViewModel = + viewModel() +) { + val context = LocalContext.current + + val allTimeUiState by + allTimeViewModel.uiState.collectAsState() + val subscribersListUiState by + subscribersListViewModel.uiState.collectAsState() + val emailsUiState by + emailsViewModel.uiState.collectAsState() + + val isAllTimeRefreshing by + allTimeViewModel.isRefreshing.collectAsState() + val isGraphRefreshing by + graphViewModel.isRefreshing.collectAsState() + val isSubscribersListRefreshing by + subscribersListViewModel + .isRefreshing.collectAsState() + val isEmailsRefreshing by + emailsViewModel.isRefreshing.collectAsState() + val isRefreshing = listOf( + isAllTimeRefreshing, + isGraphRefreshing, + isSubscribersListRefreshing, + isEmailsRefreshing + ).any { it } + val pullToRefreshState = rememberPullToRefreshState() + + val visibleCards by + subscribersTabViewModel + .visibleCards.collectAsState() + val hiddenCards by + subscribersTabViewModel + .hiddenCards.collectAsState() + val isNetworkAvailable by + subscribersTabViewModel + .isNetworkAvailable.collectAsState() + val cardsToLoad by + subscribersTabViewModel + .cardsToLoad.collectAsState() + var showAddCardSheet by + remember { mutableStateOf(false) } + val addCardSheetState = + rememberModalBottomSheetState() + + LaunchedEffect(cardsToLoad) { + cardsToLoad.dispatchToVisibleCards( + onAllTimeStats = { + allTimeViewModel.loadDataIfNeeded() + }, + onGraph = { + graphViewModel.loadDataIfNeeded() + }, + onSubscribersList = { + subscribersListViewModel + .loadDataIfNeeded() + }, + onEmails = { + emailsViewModel.loadDataIfNeeded() + } + ) + } + + if (showAddCardSheet) { + AddSubscribersCardBottomSheet( + sheetState = addCardSheetState, + availableCards = hiddenCards, + onDismiss = { showAddCardSheet = false }, + onCardSelected = { cardType -> + subscribersTabViewModel.addCard(cardType) + } + ) + } + + var showNoConnectionScreen by + remember { mutableStateOf(!isNetworkAvailable) } + + val loadVisibleCards = { + visibleCards.dispatchToVisibleCards( + onAllTimeStats = { + allTimeViewModel.loadData() + }, + onGraph = { graphViewModel.loadData() }, + onSubscribersList = { + subscribersListViewModel.loadData() + }, + onEmails = { emailsViewModel.loadData() } + ) + } + + LaunchedEffect(isNetworkAvailable) { + if (isNetworkAvailable && + showNoConnectionScreen + ) { + showNoConnectionScreen = false + loadVisibleCards() + } else if (!isNetworkAvailable && + !showNoConnectionScreen + ) { + showNoConnectionScreen = true + } + } + + if (showNoConnectionScreen) { + NoConnectionContent( + onRetry = { + val isAvailable = + subscribersTabViewModel + .checkNetworkStatus() + if (isAvailable) { + showNoConnectionScreen = false + loadVisibleCards() + } + } + ) + return + } + + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + isRefreshing = isRefreshing, + state = pullToRefreshState, + onRefresh = { + subscribersTabViewModel.checkNetworkStatus() + visibleCards.dispatchToVisibleCards( + onAllTimeStats = { + allTimeViewModel.refresh() + }, + onGraph = { + graphViewModel.refresh() + }, + onSubscribersList = { + subscribersListViewModel.refresh() + }, + onEmails = { + emailsViewModel.refresh() + } + ) + }, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + color = MaterialTheme + .colorScheme.primary, + modifier = Modifier + .align(Alignment.TopCenter) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + if (visibleCards.isEmpty()) { + val emptyMsg = stringResource( + R.string.stats_no_cards_message + ) + Text( + text = emptyMsg, + style = MaterialTheme + .typography.bodyLarge, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + .semantics { + contentDescription = emptyMsg + }, + textAlign = TextAlign.Center + ) + } + + val cardPositions = + remember(visibleCards) { + visibleCards.mapIndexed { idx, _ -> + CardPosition( + index = idx, + totalCards = + visibleCards.size + ) + } + } + + visibleCards.forEachIndexed { index, + cardType -> + val cardPosition = + cardPositions[index] + when (cardType) { + SubscribersCardType + .ALL_TIME_SUBSCRIBERS -> + AllTimeSubscribersCard( + uiState = allTimeUiState, + onRetry = { + allTimeViewModel + .loadData() + }, + onRemoveCard = { + subscribersTabViewModel + .removeCard(cardType) + }, + cardPosition = cardPosition, + onMoveUp = { + subscribersTabViewModel + .moveCardUp(cardType) + }, + onMoveToTop = { + subscribersTabViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + subscribersTabViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + subscribersTabViewModel + .moveCardToBottom( + cardType + ) + } + ) + + SubscribersCardType + .SUBSCRIBERS_GRAPH -> + SubscribersGraphCard( + onRemoveCard = { + subscribersTabViewModel + .removeCard(cardType) + }, + cardPosition = cardPosition, + onMoveUp = { + subscribersTabViewModel + .moveCardUp(cardType) + }, + onMoveToTop = { + subscribersTabViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + subscribersTabViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + subscribersTabViewModel + .moveCardToBottom( + cardType + ) + } + ) + + SubscribersCardType + .SUBSCRIBERS_LIST -> + SubscribersListCard( + uiState = + subscribersListUiState, + onShowAllClick = { + SubscribersListDetailActivity + .start( + context, + subscribersListViewModel + .getDetailData() + ) + }, + onRetry = { + subscribersListViewModel + .loadData() + }, + onRemoveCard = { + subscribersTabViewModel + .removeCard(cardType) + }, + cardPosition = cardPosition, + onMoveUp = { + subscribersTabViewModel + .moveCardUp(cardType) + }, + onMoveToTop = { + subscribersTabViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + subscribersTabViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + subscribersTabViewModel + .moveCardToBottom( + cardType + ) + } + ) + + SubscribersCardType.EMAILS -> + EmailsCard( + uiState = emailsUiState, + onShowAllClick = { + EmailsDetailActivity + .start( + context, + emailsViewModel + .getDetailData() + ) + }, + onRetry = { + emailsViewModel + .loadData() + }, + onRemoveCard = { + subscribersTabViewModel + .removeCard(cardType) + }, + cardPosition = cardPosition, + onMoveUp = { + subscribersTabViewModel + .moveCardUp(cardType) + }, + onMoveToTop = { + subscribersTabViewModel + .moveCardToTop( + cardType + ) + }, + onMoveDown = { + subscribersTabViewModel + .moveCardDown( + cardType + ) + }, + onMoveToBottom = { + subscribersTabViewModel + .moveCardToBottom( + cardType + ) + } + ) + } + } + + // Add Card Button + OutlinedButton( + onClick = { showAddCardSheet = true }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource( + R.string.stats_add_card_title + ) + ) + } + } + } +} + +private fun List + .dispatchToVisibleCards( + onAllTimeStats: () -> Unit, + onGraph: () -> Unit, + onSubscribersList: () -> Unit, + onEmails: () -> Unit +) { + if (SubscribersCardType.ALL_TIME_SUBSCRIBERS in this) + onAllTimeStats() + if (SubscribersCardType.SUBSCRIBERS_GRAPH in this) + onGraph() + if (SubscribersCardType.SUBSCRIBERS_LIST in this) + onSubscribersList() + if (SubscribersCardType.EMAILS in this) + onEmails() +} + +@Composable +private fun NoConnectionContent(onRetry: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp), + horizontalAlignment = + Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource( + R.drawable.ic_wifi_off_24px + ), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .background( + color = MaterialTheme + .colorScheme.surfaceVariant, + shape = CircleShape + ) + .padding(12.dp), + tint = MaterialTheme + .colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource( + R.string.no_connection_error_title + ), + style = MaterialTheme + .typography.titleMedium, + color = MaterialTheme + .colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource( + R.string + .no_connection_error_description + ), + style = MaterialTheme + .typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt new file mode 100644 index 000000000000..15b35a56b937 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt @@ -0,0 +1,151 @@ +package org.wordpress.android.ui.newstats.subscribers + +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.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.SubscribersCardsConfigurationRepository +import org.wordpress.android.util.NetworkUtilsWrapper +import javax.inject.Inject + +@HiltViewModel +class SubscribersTabViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val cardConfigurationRepository: + SubscribersCardsConfigurationRepository, + private val networkUtilsWrapper: NetworkUtilsWrapper +) : ViewModel() { + private val _visibleCards = MutableStateFlow< + List>( + SubscribersCardType.defaultCards() + ) + val visibleCards: StateFlow> = + _visibleCards.asStateFlow() + + private val _hiddenCards = MutableStateFlow< + List>(emptyList()) + val hiddenCards: StateFlow> = + _hiddenCards.asStateFlow() + + private val _isNetworkAvailable = + MutableStateFlow(true) + val isNetworkAvailable: StateFlow = + _isNetworkAvailable.asStateFlow() + + private val _cardsToLoad = MutableStateFlow< + List>(emptyList()) + val cardsToLoad: StateFlow> = + _cardsToLoad.asStateFlow() + + private val siteId: Long + get() = selectedSiteRepository + .getSelectedSite()?.siteId ?: 0L + + init { + checkNetworkStatus() + loadConfiguration() + observeConfigurationChanges() + } + + fun checkNetworkStatus(): Boolean { + val isAvailable = + networkUtilsWrapper.isNetworkAvailable() + _isNetworkAvailable.value = isAvailable + return isAvailable + } + + private fun loadConfiguration() { + val currentSiteId = siteId + viewModelScope.launch { + val config = cardConfigurationRepository + .getConfiguration(currentSiteId) + updateFromConfiguration(config) + } + } + + private fun observeConfigurationChanges() { + viewModelScope.launch { + cardConfigurationRepository + .configurationFlow + .collect { pair -> + val currentSiteId = siteId + if (pair != null && + pair.first == currentSiteId + ) { + updateFromConfiguration( + pair.second + ) + } + } + } + } + + private fun updateFromConfiguration( + config: SubscribersCardsConfiguration + ) { + _visibleCards.value = config.visibleCards + _hiddenCards.value = config.hiddenCards() + _cardsToLoad.value = config.visibleCards + } + + fun removeCard(cardType: SubscribersCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository.removeCard( + currentSiteId, cardType + ) + } + } + + fun addCard(cardType: SubscribersCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository.addCard( + currentSiteId, cardType + ) + } + } + + fun moveCardUp(cardType: SubscribersCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository.moveCardUp( + currentSiteId, cardType + ) + } + } + + fun moveCardToTop(cardType: SubscribersCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository.moveCardToTop( + currentSiteId, cardType + ) + } + } + + fun moveCardDown(cardType: SubscribersCardType) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository.moveCardDown( + currentSiteId, cardType + ) + } + } + + fun moveCardToBottom( + cardType: SubscribersCardType + ) { + val currentSiteId = siteId + viewModelScope.launch { + cardConfigurationRepository + .moveCardToBottom( + currentSiteId, cardType + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt new file mode 100644 index 000000000000..dee2006a9ad9 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt @@ -0,0 +1,272 @@ +package org.wordpress.android.ui.newstats.subscribers.emails + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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.util.ShimmerBox +import org.wordpress.android.ui.newstats.util.formatStatValue + +private val CardPadding = 16.dp +private const val LOADING_SHIMMER_ITEM_COUNT = 5 + +@Suppress("LongParameterList") +@Composable +fun EmailsCard( + uiState: EmailsCardUiState, + onShowAllClick: () -> Unit, + onRetry: () -> Unit, + onRemoveCard: () -> Unit, + modifier: Modifier = Modifier, + cardPosition: CardPosition? = null, + onMoveUp: (() -> Unit)? = null, + onMoveToTop: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + onMoveToBottom: (() -> Unit)? = null +) { + StatsCardContainer(modifier = modifier) { + when (uiState) { + is EmailsCardUiState.Loading -> + LoadingContent() + is EmailsCardUiState.Loaded -> + LoadedContent( + state = uiState, + onShowAllClick = onShowAllClick, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + is EmailsCardUiState.Error -> + StatsCardErrorContent( + titleResId = + R.string.stats_subscribers_emails, + errorMessageResId = + R.string.stats_error_api, + onRetry = onRetry, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + } +} + +@Composable +private fun LoadingContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + ShimmerBox( + modifier = Modifier + .width(100.dp) + .height(24.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween + ) { + ShimmerBox( + modifier = Modifier + .width(120.dp) + .height(20.dp) + ) + ShimmerBox( + modifier = Modifier + .width(50.dp) + .height(20.dp) + ) + ShimmerBox( + modifier = Modifier + .width(50.dp) + .height(20.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + repeat(LOADING_SHIMMER_ITEM_COUNT) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + ShimmerBox( + modifier = Modifier + .weight(1f) + .height(20.dp) + ) + Spacer( + modifier = Modifier.width(8.dp) + ) + ShimmerBox( + modifier = Modifier + .width(40.dp) + .height(16.dp) + ) + Spacer( + modifier = Modifier.width(8.dp) + ) + ShimmerBox( + modifier = Modifier + .width(40.dp) + .height(16.dp) + ) + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: EmailsCardUiState.Loaded, + onShowAllClick: () -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string.stats_subscribers_emails, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + if (state.items.isEmpty()) { + StatsCardEmptyContent() + } else { + // 3-column headers + EmailColumnHeaders() + Spacer(modifier = Modifier.height(8.dp)) + state.items.forEachIndexed { index, item -> + EmailItemRow(item = item) + if (index < state.items.lastIndex) { + Spacer( + modifier = Modifier.height(4.dp) + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + ShowAllFooter(onClick = onShowAllClick) + } + } +} + +@Composable +private fun EmailColumnHeaders() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string.stats_emails_latest_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource( + R.string.stats_emails_opens_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier.width(60.dp) + ) + Text( + text = stringResource( + R.string.stats_emails_clicks_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier.width(60.dp) + ) + } +} + +@Composable +private fun EmailItemRow(item: EmailListItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = formatStatValue(item.opens), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme + .colorScheme.onSurface, + modifier = Modifier.width(60.dp) + ) + Text( + text = formatStatValue(item.clicks), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme + .colorScheme.onSurface, + modifier = Modifier.width(60.dp) + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardUiState.kt new file mode 100644 index 000000000000..1c097df29e34 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardUiState.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.newstats.subscribers.emails + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * UI state for the Emails card. + */ +sealed class EmailsCardUiState { + data object Loading : EmailsCardUiState() + + data class Loaded( + val items: List + ) : EmailsCardUiState() + + data class Error( + val message: String, + val isAuthError: Boolean = false + ) : EmailsCardUiState() +} + +/** + * A single email item for display. + */ +@Parcelize +data class EmailListItem( + val title: String, + val opens: Long, + val clicks: Long +) : Parcelable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt new file mode 100644 index 000000000000..23fea193ea3e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt @@ -0,0 +1,136 @@ +package org.wordpress.android.ui.newstats.subscribers.emails + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.EmailsStatsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider +import javax.inject.Inject + +private const val CARD_MAX_ITEMS = 5 +private const val DETAIL_MAX_ITEMS = 100 + +@HiltViewModel +class EmailsCardViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = MutableStateFlow< + EmailsCardUiState>(EmailsCardUiState.Loading) + val uiState: StateFlow = + _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() + + private var isLoading = false + private var isLoadedSuccessfully = false + private var allItems: List = + emptyList() + + fun loadDataIfNeeded() { + if (isLoadedSuccessfully || isLoading) return + isLoading = true + loadData() + } + + fun refresh() { + val site = + selectedSiteRepository.getSelectedSite() + ?: return + viewModelScope.launch { + try { + _isRefreshing.value = true + loadDataInternal(site.siteId) + } finally { + _isRefreshing.value = false + } + } + } + + fun loadData() { + val site = + selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading = false + _uiState.value = EmailsCardUiState + .Error(message = "No site selected") + return + } + + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + isLoading = false + _uiState.value = EmailsCardUiState + .Error(message = "Not authenticated") + return + } + + statsRepository.init(accessToken) + _uiState.value = EmailsCardUiState.Loading + + viewModelScope.launch { + try { + loadDataInternal(site.siteId) + } finally { + isLoading = false + } + } + } + + fun getDetailData(): List = allItems + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadDataInternal(siteId: Long) { + try { + when ( + val result = statsRepository + .fetchEmailsSummary( + siteId, DETAIL_MAX_ITEMS + ) + ) { + is EmailsStatsResult.Success -> { + isLoadedSuccessfully = true + allItems = result.items.map { + EmailListItem( + title = it.title, + opens = it.opens, + clicks = it.clicks + ) + } + _uiState.value = + EmailsCardUiState.Loaded( + items = allItems.take( + CARD_MAX_ITEMS + ) + ) + } + is EmailsStatsResult.Error -> { + _uiState.value = + EmailsCardUiState.Error( + message = resourceProvider + .getString( + result.messageResId + ), + isAuthError = + result.isAuthError + ) + } + } + } catch (e: Exception) { + _uiState.value = EmailsCardUiState.Error( + message = e.message ?: "Unknown error" + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt new file mode 100644 index 000000000000..84e9feb57827 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt @@ -0,0 +1,212 @@ +package org.wordpress.android.ui.newstats.subscribers.emails + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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.util.formatStatValue +import org.wordpress.android.util.extensions.getParcelableArrayListCompat + +private const val EXTRA_ITEMS = "extra_items" + +@AndroidEntryPoint +class EmailsDetailActivity : BaseAppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val items = intent.extras + ?.getParcelableArrayListCompat( + EXTRA_ITEMS + ) ?: arrayListOf() + + setContent { + AppThemeM3 { + EmailsDetailScreen( + items = items, + onBackPressed = + onBackPressedDispatcher::onBackPressed + ) + } + } + } + + companion object { + fun start( + context: Context, + items: List + ) { + val intent = Intent( + context, + EmailsDetailActivity::class.java + ).apply { + putExtra( + EXTRA_ITEMS, ArrayList(items) + ) + } + context.startActivity(intent) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EmailsDetailScreen( + items: List, + onBackPressed: () -> Unit +) { + val title = stringResource( + R.string.stats_subscribers_emails + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + Icons.AutoMirrored.Filled + .ArrowBack, + contentDescription = + stringResource(R.string.back) + ) + } + } + ) + } + ) { contentPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp) + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + DetailEmailColumnHeaders( + itemCount = items.size + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + itemsIndexed(items) { index, item -> + DetailEmailRow(item = item) + if (index < items.lastIndex) { + Spacer( + modifier = Modifier.height(4.dp) + ) + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun DetailEmailColumnHeaders(itemCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string.stats_most_viewed_top_n, + itemCount + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource( + R.string.stats_emails_opens_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier.width(60.dp) + ) + Text( + text = stringResource( + R.string.stats_emails_clicks_header + ), + style = MaterialTheme + .typography.labelMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + modifier = Modifier.width(60.dp) + ) + } +} + +@Composable +private fun DetailEmailRow(item: EmailListItem) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = formatStatValue(item.opens), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme + .colorScheme.onSurface, + modifier = Modifier.width(60.dp) + ) + Text( + text = formatStatValue(item.clicks), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme + .colorScheme.onSurface, + modifier = Modifier.width(60.dp) + ) + } +} From 6d2773a4416c055ac65c6be0f538243ec4f07099 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 26 Feb 2026 17:18:39 +0100 Subject: [PATCH 05/21] Add unit tests for Subscribers tab cards Adds 5 test files covering the new Subscribers tab functionality: - AllTimeSubscribersViewModelTest (12 tests) - SubscribersListViewModelTest (13 tests) - EmailsCardViewModelTest (13 tests) - StatsRepositorySubscribersTest (12 tests) - SubscribersCardsConfigurationRepositoryTest (14 tests) Co-Authored-By: Claude Opus 4.6 --- .../StatsRepositorySubscribersTest.kt | 265 +++++++++++++++ ...cribersCardsConfigurationRepositoryTest.kt | 317 ++++++++++++++++++ .../AllTimeSubscribersViewModelTest.kt | 271 +++++++++++++++ .../emails/EmailsCardViewModelTest.kt | 278 +++++++++++++++ .../SubscribersListViewModelTest.kt | 273 +++++++++++++++ 5 files changed, 1404 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt new file mode 100644 index 000000000000..e128699090d7 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt @@ -0,0 +1,265 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.newstats.datasource.EmailSummaryItem +import org.wordpress.android.ui.newstats.datasource.StatsDataSource +import org.wordpress.android.ui.newstats.datasource.StatsEmailsSummaryDataResult +import org.wordpress.android.ui.newstats.datasource.StatsErrorType +import org.wordpress.android.ui.newstats.datasource.StatsSubscribersData +import org.wordpress.android.ui.newstats.datasource.StatsSubscribersDataResult +import org.wordpress.android.ui.newstats.datasource.SubscriberItem +import org.wordpress.android.ui.newstats.datasource.SubscribersByUserTypeDataResult +import org.wordpress.android.ui.newstats.datasource.SubscribersDataPoint + +@ExperimentalCoroutinesApi +class StatsRepositorySubscribersTest : BaseUnitTest() { + @Mock + private lateinit var statsDataSource: StatsDataSource + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var repository: StatsRepository + + @Before + fun setUp() { + repository = StatsRepository( + statsDataSource = statsDataSource, + appLogWrapper = appLogWrapper, + ioDispatcher = testDispatcher() + ) + } + + // region fetchSubscribersAllTime + @Test + fun `given all calls succeed, when fetchSubscribersAllTime, then counts are extracted`() = + test { + whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + .thenReturn( + StatsSubscribersDataResult.Success( + StatsSubscribersData( + subscribersData = listOf( + SubscribersDataPoint( + date = "2024-01", + count = 500L + ) + ) + ) + ) + ) + + val result = repository.fetchSubscribersAllTime(TEST_SITE_ID) + + assertThat(result).isInstanceOf(SubscribersAllTimeResult.Success::class.java) + val success = result as SubscribersAllTimeResult.Success + assertThat(success.currentCount).isEqualTo(500L) + assertThat(success.count30DaysAgo).isEqualTo(500L) + assertThat(success.count60DaysAgo).isEqualTo(500L) + assertThat(success.count90DaysAgo).isEqualTo(500L) + } + + @Test + fun `given empty subscribers data, when fetchSubscribersAllTime, then counts are zero`() = + test { + whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + .thenReturn( + StatsSubscribersDataResult.Success( + StatsSubscribersData(subscribersData = emptyList()) + ) + ) + + val result = repository.fetchSubscribersAllTime(TEST_SITE_ID) + + val success = result as SubscribersAllTimeResult.Success + assertThat(success.currentCount).isEqualTo(0L) + } + + @Test + fun `given one call fails, when fetchSubscribersAllTime, then error is returned`() = + test { + whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + .thenReturn( + StatsSubscribersDataResult.Error(StatsErrorType.API_ERROR) + ) + + val result = repository.fetchSubscribersAllTime(TEST_SITE_ID) + + assertThat(result).isInstanceOf(SubscribersAllTimeResult.Error::class.java) + val error = result as SubscribersAllTimeResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_api) + } + + @Test + fun `given auth error, when fetchSubscribersAllTime, then isAuthError is true`() = + test { + whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + .thenReturn( + StatsSubscribersDataResult.Error(StatsErrorType.AUTH_ERROR) + ) + + val result = repository.fetchSubscribersAllTime(TEST_SITE_ID) + + val error = result as SubscribersAllTimeResult.Error + assertThat(error.isAuthError).isTrue() + } + // endregion + + // region fetchSubscribersList + @Test + fun `given success, when fetchSubscribersList, then items are mapped correctly`() = + test { + whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + .thenReturn( + SubscribersByUserTypeDataResult.Success( + listOf( + SubscriberItem( + displayName = "John", + subscribedSince = "2024-01-15" + ), + SubscriberItem( + displayName = "Jane", + subscribedSince = "2024-02-20" + ) + ) + ) + ) + + val result = repository.fetchSubscribersList(TEST_SITE_ID) + + assertThat(result).isInstanceOf(SubscribersListResult.Success::class.java) + val success = result as SubscribersListResult.Success + assertThat(success.subscribers).hasSize(2) + assertThat(success.subscribers[0].displayName).isEqualTo("John") + assertThat(success.subscribers[0].subscribedSince).isEqualTo("2024-01-15") + assertThat(success.subscribers[1].displayName).isEqualTo("Jane") + } + + @Test + fun `given empty response, when fetchSubscribersList, then empty list is returned`() = + test { + whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + .thenReturn(SubscribersByUserTypeDataResult.Success(emptyList())) + + val result = repository.fetchSubscribersList(TEST_SITE_ID) + + val success = result as SubscribersListResult.Success + assertThat(success.subscribers).isEmpty() + } + + @Test + fun `given error, when fetchSubscribersList, then error result is returned`() = + test { + whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + .thenReturn( + SubscribersByUserTypeDataResult.Error(StatsErrorType.API_ERROR) + ) + + val result = repository.fetchSubscribersList(TEST_SITE_ID) + + assertThat(result).isInstanceOf(SubscribersListResult.Error::class.java) + val error = result as SubscribersListResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_api) + } + + @Test + fun `given auth error, when fetchSubscribersList, then isAuthError is true`() = + test { + whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + .thenReturn( + SubscribersByUserTypeDataResult.Error(StatsErrorType.AUTH_ERROR) + ) + + val result = repository.fetchSubscribersList(TEST_SITE_ID) + + val error = result as SubscribersListResult.Error + assertThat(error.isAuthError).isTrue() + } + // endregion + + // region fetchEmailsSummary + @Test + fun `given success, when fetchEmailsSummary, then items are mapped correctly`() = + test { + whenever(statsDataSource.fetchStatsEmailsSummary(any(), any())) + .thenReturn( + StatsEmailsSummaryDataResult.Success( + listOf( + EmailSummaryItem( + title = "Newsletter #1", + opens = 500L, + clicks = 42L + ), + EmailSummaryItem( + title = "Newsletter #2", + opens = 300L, + clicks = 25L + ) + ) + ) + ) + + val result = repository.fetchEmailsSummary(TEST_SITE_ID) + + assertThat(result).isInstanceOf(EmailsStatsResult.Success::class.java) + val success = result as EmailsStatsResult.Success + assertThat(success.items).hasSize(2) + assertThat(success.items[0].title).isEqualTo("Newsletter #1") + assertThat(success.items[0].opens).isEqualTo(500L) + assertThat(success.items[0].clicks).isEqualTo(42L) + } + + @Test + fun `given empty response, when fetchEmailsSummary, then empty list is returned`() = + test { + whenever(statsDataSource.fetchStatsEmailsSummary(any(), any())) + .thenReturn(StatsEmailsSummaryDataResult.Success(emptyList())) + + val result = repository.fetchEmailsSummary(TEST_SITE_ID) + + val success = result as EmailsStatsResult.Success + assertThat(success.items).isEmpty() + } + + @Test + fun `given error, when fetchEmailsSummary, then error result is returned`() = + test { + whenever(statsDataSource.fetchStatsEmailsSummary(any(), any())) + .thenReturn( + StatsEmailsSummaryDataResult.Error(StatsErrorType.API_ERROR) + ) + + val result = repository.fetchEmailsSummary(TEST_SITE_ID) + + assertThat(result).isInstanceOf(EmailsStatsResult.Error::class.java) + val error = result as EmailsStatsResult.Error + assertThat(error.messageResId).isEqualTo(R.string.stats_error_api) + } + + @Test + fun `given auth error, when fetchEmailsSummary, then isAuthError is true`() = + test { + whenever(statsDataSource.fetchStatsEmailsSummary(any(), any())) + .thenReturn( + StatsEmailsSummaryDataResult.Error(StatsErrorType.AUTH_ERROR) + ) + + val result = repository.fetchEmailsSummary(TEST_SITE_ID) + + val error = result as EmailsStatsResult.Error + assertThat(error.isAuthError).isTrue() + } + // endregion + + companion object { + private const val TEST_SITE_ID = 123L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt new file mode 100644 index 000000000000..75ad2b07a6e2 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt @@ -0,0 +1,317 @@ +package org.wordpress.android.ui.newstats.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.ui.newstats.subscribers.SubscribersCardType +import org.wordpress.android.ui.newstats.subscribers.SubscribersCardsConfiguration +import org.wordpress.android.ui.prefs.AppPrefsWrapper + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) +class SubscribersCardsConfigurationRepositoryTest : BaseUnitTest() { + @Mock + private lateinit var appPrefsWrapper: AppPrefsWrapper + + private lateinit var repository: SubscribersCardsConfigurationRepository + + @Before + fun setUp() { + repository = SubscribersCardsConfigurationRepository( + appPrefsWrapper, + UnconfinedTestDispatcher() + ) + } + + @Test + fun `when no saved configuration, then default configuration is returned`() = test { + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(null) + + val config = repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards).isEqualTo(SubscribersCardType.defaultCards()) + } + + @Test + fun `when valid json is saved, then configuration is parsed correctly`() = test { + val json = """ + { + "visibleCards": ["ALL_TIME_SUBSCRIBERS", "EMAILS"] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(json) + + val config = repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards).containsExactly( + SubscribersCardType.ALL_TIME_SUBSCRIBERS, + SubscribersCardType.EMAILS + ) + } + + @Test + fun `when invalid json is saved, then default config is returned and json is reset`() = + test { + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn("invalid json") + + val config = repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards).isEqualTo(SubscribersCardType.defaultCards()) + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when saveConfiguration is called, then json is saved to prefs`() = test { + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(null) + val config = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + ) + + repository.saveConfiguration(TEST_SITE_ID, config) + + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when removeCard is called, then card is removed from visible cards`() = test { + val initialJson = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "SUBSCRIBERS_GRAPH", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.removeCard(TEST_SITE_ID, SubscribersCardType.SUBSCRIBERS_GRAPH) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), jsonCaptor.capture()) + assertThat(jsonCaptor.firstValue).contains("ALL_TIME_SUBSCRIBERS") + assertThat(jsonCaptor.firstValue).contains("EMAILS") + assertThat(jsonCaptor.firstValue).doesNotContain("SUBSCRIBERS_GRAPH") + } + + @Test + fun `when addCard is called, then card is added to visible cards`() = test { + val initialJson = """ + { + "visibleCards": ["ALL_TIME_SUBSCRIBERS"] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.addCard(TEST_SITE_ID, SubscribersCardType.EMAILS) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), jsonCaptor.capture()) + assertThat(jsonCaptor.firstValue).contains("ALL_TIME_SUBSCRIBERS") + assertThat(jsonCaptor.firstValue).contains("EMAILS") + } + + @Test + fun `when moveCardUp is called, then card is moved up one position`() = test { + val initialJson = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "SUBSCRIBERS_GRAPH", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.moveCardUp(TEST_SITE_ID, SubscribersCardType.SUBSCRIBERS_GRAPH) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), jsonCaptor.capture()) + assertThat(jsonCaptor.firstValue.indexOf("SUBSCRIBERS_GRAPH")) + .isLessThan(jsonCaptor.firstValue.indexOf("ALL_TIME_SUBSCRIBERS")) + } + + @Test + fun `when moveCardUp is called on first card, then nothing changes`() = test { + val initialJson = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "SUBSCRIBERS_GRAPH", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.moveCardUp(TEST_SITE_ID, SubscribersCardType.ALL_TIME_SUBSCRIBERS) + + verify(appPrefsWrapper, never()) + .setSubscribersCardsConfigurationJson(any(), any()) + } + + @Test + fun `when moveCardDown is called, then card is moved down one position`() = test { + val initialJson = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "SUBSCRIBERS_GRAPH", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.moveCardDown(TEST_SITE_ID, SubscribersCardType.SUBSCRIBERS_GRAPH) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), jsonCaptor.capture()) + assertThat(jsonCaptor.firstValue.indexOf("SUBSCRIBERS_GRAPH")) + .isGreaterThan(jsonCaptor.firstValue.indexOf("EMAILS")) + } + + @Test + fun `when moveCardDown is called on last card, then nothing changes`() = test { + val initialJson = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "SUBSCRIBERS_GRAPH", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.moveCardDown(TEST_SITE_ID, SubscribersCardType.EMAILS) + + verify(appPrefsWrapper, never()) + .setSubscribersCardsConfigurationJson(any(), any()) + } + + @Test + fun `when moveCardToTop is called, then card is moved to first position`() = test { + val initialJson = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "SUBSCRIBERS_GRAPH", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.moveCardToTop(TEST_SITE_ID, SubscribersCardType.EMAILS) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), jsonCaptor.capture()) + assertThat(jsonCaptor.firstValue.indexOf("EMAILS")) + .isLessThan(jsonCaptor.firstValue.indexOf("ALL_TIME_SUBSCRIBERS")) + assertThat(jsonCaptor.firstValue.indexOf("EMAILS")) + .isLessThan(jsonCaptor.firstValue.indexOf("SUBSCRIBERS_GRAPH")) + } + + @Test + fun `when moveCardToBottom is called, then card is moved to last position`() = test { + val initialJson = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "SUBSCRIBERS_GRAPH", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(initialJson) + + repository.moveCardToBottom( + TEST_SITE_ID, + SubscribersCardType.ALL_TIME_SUBSCRIBERS + ) + + val jsonCaptor = argumentCaptor() + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), jsonCaptor.capture()) + assertThat(jsonCaptor.firstValue.indexOf("ALL_TIME_SUBSCRIBERS")) + .isGreaterThan(jsonCaptor.firstValue.indexOf("SUBSCRIBERS_GRAPH")) + assertThat(jsonCaptor.firstValue.indexOf("ALL_TIME_SUBSCRIBERS")) + .isGreaterThan(jsonCaptor.firstValue.indexOf("EMAILS")) + } + + @Test + fun `when config contains invalid card type, then default config is returned and json is reset`() = + test { + val jsonWithInvalidType = """ + { + "visibleCards": [ + "ALL_TIME_SUBSCRIBERS", + "INVALID_TYPE", + "EMAILS" + ] + } + """.trimIndent() + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(jsonWithInvalidType) + + val config = repository.getConfiguration(TEST_SITE_ID) + + assertThat(config.visibleCards).isEqualTo(SubscribersCardType.defaultCards()) + verify(appPrefsWrapper) + .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when configurationFlow emits, then it contains site id and configuration`() = test { + whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) + .thenReturn(null) + val config = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + ) + + repository.saveConfiguration(TEST_SITE_ID, config) + + val flowValue = repository.configurationFlow.value + assertThat(flowValue).isNotNull + assertThat(flowValue?.first).isEqualTo(TEST_SITE_ID) + assertThat(flowValue?.second?.visibleCards) + .containsExactly(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + } + + companion object { + private const val TEST_SITE_ID = 123L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt new file mode 100644 index 000000000000..83879f8f8162 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt @@ -0,0 +1,271 @@ +package org.wordpress.android.ui.newstats.subscribers.alltimestats + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.SubscribersAllTimeResult +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class AllTimeSubscribersViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: AllTimeSubscribersViewModel + + 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(resourceProvider.getString(R.string.stats_error_api)) + .thenReturn(FAILED_TO_LOAD_ERROR) + } + + private fun initViewModel() { + viewModel = AllTimeSubscribersViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadData() + } + + @Test + fun `when no site selected, then error state is emitted`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) + } + + @Test + fun `when access token is null, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) + } + + @Test + fun `when access token is empty, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn("") + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) + } + + @Test + fun `when data loads successfully, then loaded state has correct counts`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn( + SubscribersAllTimeResult.Success( + currentCount = TEST_CURRENT, + count30DaysAgo = TEST_30D, + count60DaysAgo = TEST_60D, + count90DaysAgo = TEST_90D + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Loaded::class.java) + with(state as AllTimeSubscribersUiState.Loaded) { + assertThat(currentCount).isEqualTo(TEST_CURRENT) + assertThat(count30DaysAgo).isEqualTo(TEST_30D) + assertThat(count60DaysAgo).isEqualTo(TEST_60D) + assertThat(count90DaysAgo).isEqualTo(TEST_90D) + } + } + + @Test + fun `when fetch fails with api error, then error state is emitted`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn( + SubscribersAllTimeResult.Error( + messageResId = R.string.stats_error_api + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) + assertThat((state as AllTimeSubscribersUiState.Error).message) + .isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when exception is thrown, then error state has exception message`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenThrow(RuntimeException("Test exception")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) + assertThat((state as AllTimeSubscribersUiState.Error).message) + .isEqualTo("Test exception") + } + + @Test + fun `when exception with null message is thrown, then error has unknown error`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenThrow(RuntimeException()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) + assertThat((state as AllTimeSubscribersUiState.Error).message) + .isEqualTo("Unknown error") + } + + @Test + fun `when loadDataIfNeeded called multiple times, then data is only loaded once`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn(createSuccessResult()) + + viewModel = AllTimeSubscribersViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsRepository, times(1)).fetchSubscribersAllTime(eq(TEST_SITE_ID)) + } + + @Test + fun `when loadData is called again, then repository is called again`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.loadData() + advanceUntilIdle() + + verify(statsRepository, times(2)).fetchSubscribersAllTime(eq(TEST_SITE_ID)) + } + + @Test + fun `when refresh is called, then isRefreshing becomes false after completion`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + } + + @Test + fun `when refresh is called, then data is fetched again`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + verify(statsRepository, times(2)).fetchSubscribersAllTime(eq(TEST_SITE_ID)) + } + + @Test + fun `when data loads with zero values, then loaded state shows zeros`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn( + SubscribersAllTimeResult.Success( + currentCount = 0L, + count30DaysAgo = 0L, + count60DaysAgo = 0L, + count90DaysAgo = 0L + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as AllTimeSubscribersUiState.Loaded + assertThat(state.currentCount).isEqualTo(0L) + assertThat(state.count30DaysAgo).isEqualTo(0L) + assertThat(state.count60DaysAgo).isEqualTo(0L) + assertThat(state.count90DaysAgo).isEqualTo(0L) + } + + private fun createSuccessResult() = SubscribersAllTimeResult.Success( + currentCount = TEST_CURRENT, + count30DaysAgo = TEST_30D, + count60DaysAgo = TEST_60D, + count90DaysAgo = TEST_90D + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val TEST_CURRENT = 1000L + private const val TEST_30D = 950L + private const val TEST_60D = 900L + private const val TEST_90D = 850L + private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt new file mode 100644 index 000000000000..9a4b5e40f483 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt @@ -0,0 +1,278 @@ +package org.wordpress.android.ui.newstats.subscribers.emails + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.EmailItemData +import org.wordpress.android.ui.newstats.repository.EmailsStatsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class EmailsCardViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: EmailsCardViewModel + + 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(resourceProvider.getString(R.string.stats_error_api)) + .thenReturn(FAILED_TO_LOAD_ERROR) + } + + private fun initViewModel() { + viewModel = EmailsCardViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadData() + } + + @Test + fun `when no site selected, then error state is emitted`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(EmailsCardUiState.Error::class.java) + } + + @Test + fun `when access token is null, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(EmailsCardUiState.Error::class.java) + } + + @Test + fun `when access token is empty, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn("") + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(EmailsCardUiState.Error::class.java) + } + + @Test + fun `when data loads successfully, then loaded state has items truncated to 5`() = test { + val items = (1..10).map { + EmailItemData( + title = "Email $it", + opens = it.toLong() * 100, + clicks = it.toLong() * 10 + ) + } + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(EmailsCardUiState.Loaded::class.java) + assertThat((state as EmailsCardUiState.Loaded).items).hasSize(5) + } + + @Test + fun `when data has fewer than 5 items, then all items are shown`() = test { + val items = (1..3).map { + EmailItemData( + title = "Email $it", + opens = it.toLong() * 100, + clicks = it.toLong() * 10 + ) + } + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as EmailsCardUiState.Loaded + assertThat(state.items).hasSize(3) + } + + @Test + fun `when data loads with empty list, then loaded state has empty items`() = test { + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(emptyList())) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as EmailsCardUiState.Loaded + assertThat(state.items).isEmpty() + } + + @Test + fun `when fetch fails, then error state is emitted`() = test { + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn( + EmailsStatsResult.Error( + messageResId = R.string.stats_error_api + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(EmailsCardUiState.Error::class.java) + assertThat((state as EmailsCardUiState.Error).message) + .isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when exception is thrown, then error state has exception message`() = test { + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenThrow(RuntimeException("Test exception")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(EmailsCardUiState.Error::class.java) + assertThat((state as EmailsCardUiState.Error).message) + .isEqualTo("Test exception") + } + + @Test + fun `when loadDataIfNeeded called multiple times, then data is only loaded once`() = test { + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(createTestItems())) + + viewModel = EmailsCardViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsRepository, times(1)).fetchEmailsSummary(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when loadData is called again, then repository is called again`() = test { + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(createTestItems())) + + initViewModel() + advanceUntilIdle() + + viewModel.loadData() + advanceUntilIdle() + + verify(statsRepository, times(2)).fetchEmailsSummary(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when refresh is called, then isRefreshing becomes false after completion`() = test { + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(createTestItems())) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + } + + @Test + fun `when getDetailData is called, then all items are returned not truncated`() = test { + val items = (1..10).map { + EmailItemData( + title = "Email $it", + opens = it.toLong() * 100, + clicks = it.toLong() * 10 + ) + } + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.getDetailData()).hasSize(10) + } + + @Test + fun `when data loads, then items map title opens and clicks correctly`() = test { + val items = listOf( + EmailItemData( + title = "My Newsletter", + opens = 500L, + clicks = 42L + ) + ) + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as EmailsCardUiState.Loaded + assertThat(state.items[0].title).isEqualTo("My Newsletter") + assertThat(state.items[0].opens).isEqualTo(500L) + assertThat(state.items[0].clicks).isEqualTo(42L) + } + + private fun createTestItems() = listOf( + EmailItemData(title = "Email 1", opens = 100L, clicks = 10L), + EmailItemData(title = "Email 2", opens = 200L, clicks = 20L) + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt new file mode 100644 index 000000000000..9c87368ece52 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt @@ -0,0 +1,273 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.SubscriberItemData +import org.wordpress.android.ui.newstats.repository.SubscribersListResult +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +class SubscribersListViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: SubscribersListViewModel + + 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(resourceProvider.getString(R.string.stats_error_api)) + .thenReturn(FAILED_TO_LOAD_ERROR) + } + + private fun initViewModel() { + viewModel = SubscribersListViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadData() + } + + @Test + fun `when no site selected, then error state is emitted`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(SubscribersListUiState.Error::class.java) + } + + @Test + fun `when access token is null, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn(null) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(SubscribersListUiState.Error::class.java) + } + + @Test + fun `when access token is empty, then error state is emitted`() = test { + whenever(accountStore.accessToken).thenReturn("") + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(SubscribersListUiState.Error::class.java) + } + + @Test + fun `when data loads successfully, then loaded state has items truncated to 5`() = test { + val items = (1..10).map { + SubscriberItemData( + displayName = "User $it", + subscribedSince = "2024-01-0$it" + ) + } + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(SubscribersListUiState.Loaded::class.java) + assertThat((state as SubscribersListUiState.Loaded).items).hasSize(5) + } + + @Test + fun `when data has fewer than 5 items, then all items are shown`() = test { + val items = (1..3).map { + SubscriberItemData( + displayName = "User $it", + subscribedSince = "2024-01-0$it" + ) + } + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as SubscribersListUiState.Loaded + assertThat(state.items).hasSize(3) + } + + @Test + fun `when data loads with empty list, then loaded state has empty items`() = test { + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(emptyList())) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as SubscribersListUiState.Loaded + assertThat(state.items).isEmpty() + } + + @Test + fun `when fetch fails, then error state is emitted`() = test { + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn( + SubscribersListResult.Error( + messageResId = R.string.stats_error_api + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(SubscribersListUiState.Error::class.java) + assertThat((state as SubscribersListUiState.Error).message) + .isEqualTo(FAILED_TO_LOAD_ERROR) + } + + @Test + fun `when exception is thrown, then error state has exception message`() = test { + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenThrow(RuntimeException("Test exception")) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf(SubscribersListUiState.Error::class.java) + assertThat((state as SubscribersListUiState.Error).message) + .isEqualTo("Test exception") + } + + @Test + fun `when loadDataIfNeeded called multiple times, then data is only loaded once`() = test { + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(createTestItems())) + + viewModel = SubscribersListViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsRepository, times(1)).fetchSubscribersList(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when loadData is called again, then repository is called again`() = test { + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(createTestItems())) + + initViewModel() + advanceUntilIdle() + + viewModel.loadData() + advanceUntilIdle() + + verify(statsRepository, times(2)).fetchSubscribersList(eq(TEST_SITE_ID), any()) + } + + @Test + fun `when refresh is called, then isRefreshing becomes false after completion`() = test { + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(createTestItems())) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + } + + @Test + fun `when getDetailData is called, then all items are returned not truncated`() = test { + val items = (1..10).map { + SubscriberItemData( + displayName = "User $it", + subscribedSince = "2024-01-0$it" + ) + } + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.getDetailData()).hasSize(10) + } + + @Test + fun `when data loads, then items map displayName and subscribedSince correctly`() = test { + val items = listOf( + SubscriberItemData( + displayName = "John Doe", + subscribedSince = "2024-06-15T10:00:00" + ) + ) + whenever(statsRepository.fetchSubscribersList(any(), any())) + .thenReturn(SubscribersListResult.Success(items)) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value as SubscribersListUiState.Loaded + assertThat(state.items[0].displayName).isEqualTo("John Doe") + assertThat(state.items[0].subscribedSince).isEqualTo("2024-06-15T10:00:00") + } + + private fun createTestItems() = listOf( + SubscriberItemData(displayName = "User 1", subscribedSince = "2024-01-01"), + SubscriberItemData(displayName = "User 2", subscribedSince = "2024-01-02") + ) + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + } +} From cc25b2b9e9edbb2f397e734d35c1887a2a67bfd5 Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 26 Feb 2026 20:58:33 +0100 Subject: [PATCH 06/21] Add tests for SubscribersTabViewModel and SubscribersGraphViewModel - SubscribersTabViewModelTest (21 tests): config loading, card management (add/remove/move), flow observation, network status - SubscribersGraphViewModelTest (6 tests): placeholder state behavior Co-Authored-By: Claude Opus 4.6 --- .../SubscribersTabViewModelTest.kt | 341 ++++++++++++++++++ .../SubscribersGraphViewModelTest.kt | 69 ++++ 2 files changed, 410 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt new file mode 100644 index 000000000000..f6891e3d59b3 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt @@ -0,0 +1,341 @@ +package org.wordpress.android.ui.newstats.subscribers + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.SubscribersCardsConfigurationRepository +import org.wordpress.android.util.NetworkUtilsWrapper + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) +class SubscribersTabViewModelTest : BaseUnitTest(StandardTestDispatcher()) { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var cardConfigurationRepository: SubscribersCardsConfigurationRepository + + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + + private lateinit var viewModel: SubscribersTabViewModel + + private val testSite = SiteModel().apply { + id = 1 + siteId = TEST_SITE_ID + name = "Test Site" + } + + private val configurationFlow = + MutableStateFlow?>(null) + + @Before + fun setUp() { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(testSite) + whenever(cardConfigurationRepository.configurationFlow).thenReturn(configurationFlow) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + } + + private suspend fun initViewModel( + config: SubscribersCardsConfiguration = SubscribersCardsConfiguration() + ) { + whenever(cardConfigurationRepository.getConfiguration(TEST_SITE_ID)).thenReturn(config) + viewModel = SubscribersTabViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper + ) + } + + @Test + fun `when initialized with default config, then default cards are visible`() = test { + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value).isEqualTo(SubscribersCardType.defaultCards()) + } + + @Test + fun `when initialized with custom config, then custom cards are visible`() = test { + val customConfig = SubscribersCardsConfiguration( + visibleCards = listOf( + SubscribersCardType.ALL_TIME_SUBSCRIBERS, + SubscribersCardType.EMAILS + ) + ) + initViewModel(customConfig) + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value).containsExactly( + SubscribersCardType.ALL_TIME_SUBSCRIBERS, + SubscribersCardType.EMAILS + ) + } + + @Test + fun `when removeCard is called, then repository removeCard is invoked`() = test { + initViewModel() + advanceUntilIdle() + + viewModel.removeCard(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + advanceUntilIdle() + + verify(cardConfigurationRepository).removeCard( + TEST_SITE_ID, + SubscribersCardType.ALL_TIME_SUBSCRIBERS + ) + } + + @Test + fun `when addCard is called, then repository addCard is invoked`() = test { + val config = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + ) + initViewModel(config) + advanceUntilIdle() + + viewModel.addCard(SubscribersCardType.EMAILS) + advanceUntilIdle() + + verify(cardConfigurationRepository).addCard(TEST_SITE_ID, SubscribersCardType.EMAILS) + } + + @Test + fun `when configuration changes via flow, then state is updated`() = test { + initViewModel() + advanceUntilIdle() + + val newConfig = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.EMAILS) + ) + configurationFlow.value = TEST_SITE_ID to newConfig + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value).containsExactly(SubscribersCardType.EMAILS) + } + + @Test + fun `when configuration changes for different site, then state is not updated`() = test { + initViewModel() + advanceUntilIdle() + val initialCards = viewModel.visibleCards.value + + val newConfig = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.EMAILS) + ) + configurationFlow.value = OTHER_SITE_ID to newConfig + advanceUntilIdle() + + assertThat(viewModel.visibleCards.value).isEqualTo(initialCards) + } + + @Test + fun `when hiddenCards is calculated, then it excludes visible cards`() = test { + val config = SubscribersCardsConfiguration( + visibleCards = listOf( + SubscribersCardType.ALL_TIME_SUBSCRIBERS, + SubscribersCardType.EMAILS + ) + ) + initViewModel(config) + advanceUntilIdle() + + val hiddenCards = viewModel.hiddenCards.value + + assertThat(hiddenCards).contains( + SubscribersCardType.SUBSCRIBERS_GRAPH, + SubscribersCardType.SUBSCRIBERS_LIST + ) + assertThat(hiddenCards).doesNotContain( + SubscribersCardType.ALL_TIME_SUBSCRIBERS, + SubscribersCardType.EMAILS + ) + } + + @Test + fun `when no site selected, then siteId defaults to 0`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + whenever(cardConfigurationRepository.getConfiguration(0L)) + .thenReturn(SubscribersCardsConfiguration()) + + viewModel = SubscribersTabViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper + ) + advanceUntilIdle() + + verify(cardConfigurationRepository).getConfiguration(0L) + } + + // region Move card tests + @Test + fun `when moveCardUp is called, then repository moveCardUp is invoked`() = test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardUp(SubscribersCardType.SUBSCRIBERS_GRAPH) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardUp( + TEST_SITE_ID, + SubscribersCardType.SUBSCRIBERS_GRAPH + ) + } + + @Test + fun `when moveCardToTop is called, then repository moveCardToTop is invoked`() = test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardToTop(SubscribersCardType.EMAILS) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardToTop( + TEST_SITE_ID, + SubscribersCardType.EMAILS + ) + } + + @Test + fun `when moveCardDown is called, then repository moveCardDown is invoked`() = test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardDown(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardDown( + TEST_SITE_ID, + SubscribersCardType.ALL_TIME_SUBSCRIBERS + ) + } + + @Test + fun `when moveCardToBottom is called, then repository moveCardToBottom is invoked`() = test { + initViewModel() + advanceUntilIdle() + + viewModel.moveCardToBottom(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + advanceUntilIdle() + + verify(cardConfigurationRepository).moveCardToBottom( + TEST_SITE_ID, + SubscribersCardType.ALL_TIME_SUBSCRIBERS + ) + } + // endregion + + // region cardsToLoad tests + @Test + fun `when ViewModel is created, then cardsToLoad starts empty`() = test { + whenever(cardConfigurationRepository.getConfiguration(TEST_SITE_ID)) + .thenReturn(SubscribersCardsConfiguration()) + + viewModel = SubscribersTabViewModel( + selectedSiteRepository, + cardConfigurationRepository, + networkUtilsWrapper + ) + + assertThat(viewModel.cardsToLoad.value).isEmpty() + } + + @Test + fun `when config loads, then cardsToLoad matches visible cards`() = test { + val config = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.EMAILS) + ) + initViewModel(config) + advanceUntilIdle() + + assertThat(viewModel.cardsToLoad.value).containsExactly(SubscribersCardType.EMAILS) + } + + @Test + fun `when config loads with default, then cardsToLoad matches default cards`() = test { + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.cardsToLoad.value).isEqualTo(SubscribersCardType.defaultCards()) + } + + @Test + fun `when configuration changes via flow, then cardsToLoad is updated`() = test { + initViewModel() + advanceUntilIdle() + + val newConfig = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.SUBSCRIBERS_LIST) + ) + configurationFlow.value = TEST_SITE_ID to newConfig + advanceUntilIdle() + + assertThat(viewModel.cardsToLoad.value) + .containsExactly(SubscribersCardType.SUBSCRIBERS_LIST) + } + // endregion + + // region Network availability tests + @Test + fun `when initialized with network available, then isNetworkAvailable is true`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isNetworkAvailable.value).isTrue() + } + + @Test + fun `when initialized without network, then isNetworkAvailable is false`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isNetworkAvailable.value).isFalse() + } + + @Test + fun `when checkNetworkStatus is called, then network status is updated`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + initViewModel() + advanceUntilIdle() + + assertThat(viewModel.isNetworkAvailable.value).isFalse() + + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + viewModel.checkNetworkStatus() + + assertThat(viewModel.isNetworkAvailable.value).isTrue() + } + + @Test + fun `when checkNetworkStatus is called, then it returns current status`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + initViewModel() + advanceUntilIdle() + + val result = viewModel.checkNetworkStatus() + + assertThat(result).isTrue() + } + // endregion + + companion object { + private const val TEST_SITE_ID = 123L + private const val OTHER_SITE_ID = 456L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt new file mode 100644 index 000000000000..6e0ba36fffce --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.ui.newstats.subscribers.subscribersgraph + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.BaseUnitTest + +@ExperimentalCoroutinesApi +class SubscribersGraphViewModelTest : BaseUnitTest() { + private lateinit var viewModel: SubscribersGraphViewModel + + private fun initViewModel() { + viewModel = SubscribersGraphViewModel() + } + + @Test + fun `when initialized, then ui state is Placeholder`() = test { + initViewModel() + + assertThat(viewModel.uiState.value) + .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) + } + + @Test + fun `when initialized, then isRefreshing is false`() = test { + initViewModel() + + assertThat(viewModel.isRefreshing.value).isFalse() + } + + @Test + fun `when loadDataIfNeeded is called, then state remains Placeholder`() = test { + initViewModel() + + viewModel.loadDataIfNeeded() + + assertThat(viewModel.uiState.value) + .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) + } + + @Test + fun `when refresh is called, then state remains Placeholder`() = test { + initViewModel() + + viewModel.refresh() + + assertThat(viewModel.uiState.value) + .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) + } + + @Test + fun `when loadData is called, then state remains Placeholder`() = test { + initViewModel() + + viewModel.loadData() + + assertThat(viewModel.uiState.value) + .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) + } + + @Test + fun `when refresh is called, then isRefreshing remains false`() = test { + initViewModel() + + viewModel.refresh() + + assertThat(viewModel.isRefreshing.value).isFalse() + } +} From 0f522e8e4adff645306c87ae8d3e8ba8133a6fe7 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 11:34:31 +0100 Subject: [PATCH 07/21] Improve All-time Subscribers card UI and fix API calls - Redesign card with highlighted Current sub-card and 3 smaller sub-cards for 30/60/90 days ago - Fix subscribers API: use DAY unit instead of MONTH - Pass correct date parameter (today, -30d, -60d, -90d) for each call - Hide period selector when Subscribers tab is selected - Update tests for new date parameter Co-Authored-By: Claude Opus 4.6 --- .../android/ui/newstats/NewStatsActivity.kt | 78 +++++---- .../ui/newstats/datasource/StatsDataSource.kt | 6 +- .../datasource/StatsDataSourceImpl.kt | 6 +- .../ui/newstats/repository/StatsRepository.kt | 27 ++- .../alltimestats/AllTimeSubscribersCard.kt | 159 +++++++++++------- .../StatsRepositorySubscribersTest.kt | 9 +- 6 files changed, 177 insertions(+), 108 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index da8a8de48959..0195c3ba6242 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 @@ -165,39 +165,55 @@ private fun NewStatsScreen( } }, actions = { - Box { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { showPeriodMenu = true } - .padding(horizontal = 8.dp) - ) { - Text( - text = selectedPeriod.getDisplayLabel(), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - Icon( - imageVector = Icons.Default.DateRange, - contentDescription = stringResource( - R.string.stats_period_selector_content_description - ), - modifier = Modifier.padding(start = 4.dp) + val currentTab = tabs[pagerState.currentPage] + if (currentTab != StatsTab.SUBSCRIBERS) { + Box { + Row( + verticalAlignment = + Alignment.CenterVertically, + modifier = Modifier + .clickable { + showPeriodMenu = true + } + .padding(horizontal = 8.dp) + ) { + Text( + text = selectedPeriod + .getDisplayLabel(), + style = MaterialTheme + .typography.labelLarge, + color = MaterialTheme + .colorScheme.onSurface + ) + Icon( + imageVector = + Icons.Default.DateRange, + contentDescription = + stringResource( + R.string + .stats_period_selector_content_description + ), + modifier = Modifier + .padding(start = 4.dp) + ) + } + StatsPeriodMenu( + expanded = showPeriodMenu, + selectedPeriod = selectedPeriod, + onDismiss = { + showPeriodMenu = false + }, + onPresetSelected = { period -> + viewsStatsViewModel + .onPeriodChanged(period) + showPeriodMenu = false + }, + onCustomSelected = { + showPeriodMenu = false + showDateRangePicker = true + } ) } - StatsPeriodMenu( - expanded = showPeriodMenu, - selectedPeriod = selectedPeriod, - onDismiss = { showPeriodMenu = false }, - onPresetSelected = { period -> - viewsStatsViewModel.onPeriodChanged(period) - showPeriodMenu = false - }, - onCustomSelected = { - showPeriodMenu = false - showDateRangePicker = true - } - ) } } ) 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 803a31bbde0d..f9a5a1620282 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 @@ -213,11 +213,13 @@ interface StatsDataSource { * * @param siteId The WordPress.com site ID * @param quantity Number of data points to return - * @return Result containing subscriber count data or an error + * @param date Optional date in YYYY-MM-DD format + * @return Result containing subscriber count data */ suspend fun fetchStatsSubscribers( siteId: Long, - quantity: Int = 1 + quantity: Int = 1, + date: String? = null ): StatsSubscribersDataResult /** 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 46324b9667bb..ce96b84e3dff 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 @@ -995,11 +995,13 @@ class StatsDataSourceImpl @Inject constructor( override suspend fun fetchStatsSubscribers( siteId: Long, - quantity: Int + quantity: Int, + date: String? ): StatsSubscribersDataResult { val params = StatsSubscribersParams( - unit = StatsSubscribersUnit.MONTH, + unit = StatsSubscribersUnit.DAY, quantity = quantity.toUInt(), + date = date, statFields = listOf( StatsSubscribersStatField.SUBSCRIBERS ) 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 9d4d87972202..fb918593509b 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 @@ -1333,25 +1333,44 @@ class StatsRepository @Inject constructor( suspend fun fetchSubscribersAllTime( siteId: Long ): SubscribersAllTimeResult = withContext(ioDispatcher) { + val today = java.time.LocalDate.now() + val dateFormat = + java.time.format.DateTimeFormatter.ISO_LOCAL_DATE + val todayStr = today.format(dateFormat) + val d30Str = today.minusDays(30) + .format(dateFormat) + val d60Str = today.minusDays(60) + .format(dateFormat) + val d90Str = today.minusDays(90) + .format(dateFormat) + val (current, d30, d60, d90) = coroutineScope { val currentDef = async { statsDataSource.fetchStatsSubscribers( - siteId, quantity = 1 + siteId, + quantity = 1, + date = todayStr ) } val d30Def = async { statsDataSource.fetchStatsSubscribers( - siteId, quantity = 1 + siteId, + quantity = 1, + date = d30Str ) } val d60Def = async { statsDataSource.fetchStatsSubscribers( - siteId, quantity = 1 + siteId, + quantity = 1, + date = d60Str ) } val d90Def = async { statsDataSource.fetchStatsSubscribers( - siteId, quantity = 1 + siteId, + quantity = 1, + date = d90Str ) } listOf( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt index 52f2f4ef0665..b522ed3fdfb1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.subscribers.alltimestats +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -7,11 +8,13 @@ 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.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -25,6 +28,7 @@ import org.wordpress.android.ui.newstats.util.ShimmerBox import org.wordpress.android.ui.newstats.util.formatStatValue private val CardPadding = 16.dp +private val SubCardCornerRadius = 8.dp @Suppress("LongParameterList") @Composable @@ -83,52 +87,32 @@ private fun LoadingContent() { .fillMaxWidth(0.4f) .height(24.dp) ) - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = - Arrangement.SpaceBetween - ) { - repeat(2) { - Column { - ShimmerBox( - modifier = Modifier - .height(32.dp) - .fillMaxWidth(0.3f) - ) - Spacer( - modifier = Modifier.height(4.dp) - ) - ShimmerBox( - modifier = Modifier - .height(16.dp) - .fillMaxWidth(0.25f) - ) - } - } - } Spacer(modifier = Modifier.height(12.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clip( + RoundedCornerShape(SubCardCornerRadius) + ) + ) + Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = - Arrangement.SpaceBetween + Arrangement.spacedBy(8.dp) ) { - repeat(2) { - Column { - ShimmerBox( - modifier = Modifier - .height(32.dp) - .fillMaxWidth(0.3f) - ) - Spacer( - modifier = Modifier.height(4.dp) - ) - ShimmerBox( - modifier = Modifier - .height(16.dp) - .fillMaxWidth(0.25f) - ) - } + repeat(3) { + ShimmerBox( + modifier = Modifier + .weight(1f) + .height(52.dp) + .clip( + RoundedCornerShape( + SubCardCornerRadius + ) + ) + ) } } } @@ -160,63 +144,108 @@ private fun LoadedContent( onMoveDown = onMoveDown, onMoveToBottom = onMoveToBottom ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) + HighlightedStatItem( + label = stringResource( + R.string.stats_subscribers_current + ), + value = state.currentCount + ) + Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = - Arrangement.SpaceEvenly + Arrangement.spacedBy(8.dp) ) { - StatItem( - label = stringResource( - R.string.stats_subscribers_current - ), - value = state.currentCount - ) StatItem( label = stringResource( R.string.stats_subscribers_30_days_ago ), - value = state.count30DaysAgo + value = state.count30DaysAgo, + modifier = Modifier.weight(1f) ) - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = - Arrangement.SpaceEvenly - ) { StatItem( label = stringResource( R.string.stats_subscribers_60_days_ago ), - value = state.count60DaysAgo + value = state.count60DaysAgo, + modifier = Modifier.weight(1f) ) StatItem( label = stringResource( R.string.stats_subscribers_90_days_ago ), - value = state.count90DaysAgo + value = state.count90DaysAgo, + modifier = Modifier.weight(1f) ) } } } @Composable -private fun StatItem(label: String, value: Long) { +private fun HighlightedStatItem( + label: String, + value: Long +) { Column( - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(SubCardCornerRadius)) + .background( + MaterialTheme.colorScheme.primary + .copy(alpha = 0.08f) + ) + .padding( + horizontal = 12.dp, + vertical = 12.dp + ) ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) Text( text = formatStatValue(value), - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.primary ) + } +} + +@Composable +private fun StatItem( + label: String, + value: Long, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(SubCardCornerRadius)) + .background( + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.5f) + ) + .padding( + horizontal = 12.dp, + vertical = 10.dp + ) + ) { Text( - text = label, - style = MaterialTheme.typography.labelMedium, + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatStatValue(value), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt index e128699090d7..4154b67c961a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt @@ -6,6 +6,7 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R @@ -43,7 +44,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given all calls succeed, when fetchSubscribersAllTime, then counts are extracted`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Success( StatsSubscribersData( @@ -70,7 +71,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given empty subscribers data, when fetchSubscribersAllTime, then counts are zero`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Success( StatsSubscribersData(subscribersData = emptyList()) @@ -86,7 +87,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given one call fails, when fetchSubscribersAllTime, then error is returned`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Error(StatsErrorType.API_ERROR) ) @@ -101,7 +102,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given auth error, when fetchSubscribersAllTime, then isAuthError is true`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Error(StatsErrorType.AUTH_ERROR) ) From 084badb286da881bba8d771bf63ac07150a2568d Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 11:57:58 +0100 Subject: [PATCH 08/21] Improve Emails card alignment and formatting - Center-align opens/clicks columns in headers and rows - Add divider between column headers and items - Show "-" instead of "0" for zero opens/clicks values - Use lighter color for zero values to de-emphasize - Add proper spacing between title and numeric columns - Remove "Top N" truncation label from detail screen Co-Authored-By: Claude Opus 4.6 --- .../newstats/subscribers/emails/EmailsCard.kt | 59 ++++++++++++------ .../emails/EmailsDetailActivity.kt | 60 +++++++++++++------ 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt index dee2006a9ad9..519324abbb30 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt @@ -8,6 +8,7 @@ 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.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.wordpress.android.R @@ -179,16 +181,9 @@ private fun LoadedContent( if (state.items.isEmpty()) { StatsCardEmptyContent() } else { - // 3-column headers EmailColumnHeaders() - Spacer(modifier = Modifier.height(8.dp)) - state.items.forEachIndexed { index, item -> + state.items.forEach { item -> EmailItemRow(item = item) - if (index < state.items.lastIndex) { - Spacer( - modifier = Modifier.height(4.dp) - ) - } } Spacer(modifier = Modifier.height(12.dp)) ShowAllFooter(onClick = onShowAllClick) @@ -220,8 +215,10 @@ private fun EmailColumnHeaders() { .typography.labelMedium, color = MaterialTheme .colorScheme.onSurfaceVariant, - modifier = Modifier.width(60.dp) + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) + Spacer(modifier = Modifier.width(12.dp)) Text( text = stringResource( R.string.stats_emails_clicks_header @@ -230,9 +227,15 @@ private fun EmailColumnHeaders() { .typography.labelMedium, color = MaterialTheme .colorScheme.onSurfaceVariant, - modifier = Modifier.width(60.dp) + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) } + Spacer(modifier = Modifier.height(4.dp)) + HorizontalDivider( + color = MaterialTheme + .colorScheme.outlineVariant + ) } @Composable @@ -240,7 +243,7 @@ private fun EmailItemRow(item: EmailListItem) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -252,21 +255,39 @@ private fun EmailItemRow(item: EmailListItem) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = formatStatValue(item.opens), + text = formatEmailStat(item.opens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme - .colorScheme.onSurface, - modifier = Modifier.width(60.dp) + color = if (item.opens == 0L) { + MaterialTheme + .colorScheme.onSurfaceVariant + } else { + MaterialTheme + .colorScheme.onSurface + }, + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) + Spacer(modifier = Modifier.width(12.dp)) Text( - text = formatStatValue(item.clicks), + text = formatEmailStat(item.clicks), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme - .colorScheme.onSurface, - modifier = Modifier.width(60.dp) + color = if (item.clicks == 0L) { + MaterialTheme + .colorScheme.onSurfaceVariant + } else { + MaterialTheme + .colorScheme.onSurface + }, + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) } } + +private fun formatEmailStat(value: Long): String { + return if (value == 0L) "-" else formatStatValue(value) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt index 84e9feb57827..f31fb9c8dd95 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -17,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dagger.hilt.android.AndroidEntryPoint @@ -113,9 +114,7 @@ private fun EmailsDetailScreen( ) { item { Spacer(modifier = Modifier.height(8.dp)) - DetailEmailColumnHeaders( - itemCount = items.size - ) + DetailEmailColumnHeaders() Spacer(modifier = Modifier.height(8.dp)) } @@ -136,15 +135,14 @@ private fun EmailsDetailScreen( } @Composable -private fun DetailEmailColumnHeaders(itemCount: Int) { +private fun DetailEmailColumnHeaders() { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource( - R.string.stats_most_viewed_top_n, - itemCount + R.string.stats_emails_latest_header ), style = MaterialTheme .typography.labelMedium, @@ -160,8 +158,10 @@ private fun DetailEmailColumnHeaders(itemCount: Int) { .typography.labelMedium, color = MaterialTheme .colorScheme.onSurfaceVariant, - modifier = Modifier.width(60.dp) + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) + Spacer(modifier = Modifier.width(12.dp)) Text( text = stringResource( R.string.stats_emails_clicks_header @@ -170,9 +170,15 @@ private fun DetailEmailColumnHeaders(itemCount: Int) { .typography.labelMedium, color = MaterialTheme .colorScheme.onSurfaceVariant, - modifier = Modifier.width(60.dp) + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) } + Spacer(modifier = Modifier.height(4.dp)) + HorizontalDivider( + color = MaterialTheme + .colorScheme.outlineVariant + ) } @Composable @@ -180,7 +186,7 @@ private fun DetailEmailRow(item: EmailListItem) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), + .padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -192,21 +198,39 @@ private fun DetailEmailRow(item: EmailListItem) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = formatStatValue(item.opens), + text = formatEmailStat(item.opens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme - .colorScheme.onSurface, - modifier = Modifier.width(60.dp) + color = if (item.opens == 0L) { + MaterialTheme + .colorScheme.onSurfaceVariant + } else { + MaterialTheme + .colorScheme.onSurface + }, + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) + Spacer(modifier = Modifier.width(12.dp)) Text( - text = formatStatValue(item.clicks), + text = formatEmailStat(item.clicks), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme - .colorScheme.onSurface, - modifier = Modifier.width(60.dp) + color = if (item.clicks == 0L) { + MaterialTheme + .colorScheme.onSurfaceVariant + } else { + MaterialTheme + .colorScheme.onSurface + }, + textAlign = TextAlign.Center, + modifier = Modifier.width(56.dp) ) } } + +private fun formatEmailStat(value: Long): String { + return if (value == 0L) "-" else formatStatValue(value) +} From 63d6a36b93bf5307e5e85848b9866f1f6a4852d0 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 12:22:55 +0100 Subject: [PATCH 09/21] Implement Subscribers Graph card with line chart and period tabs Replace the placeholder graph card with a Vico line chart showing subscriber counts over time. Add segmented button tabs for switching between Days (30d), Weeks (12w), Months (6m), and Years (3y) periods. Data is sorted chronologically (older to newer). Co-Authored-By: Claude Opus 4.6 --- .../ui/newstats/datasource/StatsDataSource.kt | 2 + .../datasource/StatsDataSourceImpl.kt | 9 +- .../ui/newstats/repository/StatsRepository.kt | 69 ++++ .../subscribers/SubscribersTabContent.kt | 14 + .../subscribersgraph/SubscribersGraphCard.kt | 353 ++++++++++++++++-- .../SubscribersGraphUiState.kt | 36 +- .../SubscribersGraphViewModel.kt | 180 ++++++++- WordPress/src/main/res/values/strings.xml | 4 + .../StatsRepositorySubscribersTest.kt | 8 +- .../SubscribersGraphViewModelTest.kt | 326 ++++++++++++++-- 10 files changed, 918 insertions(+), 83 deletions(-) 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 f9a5a1620282..bc155ef2f367 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 @@ -213,12 +213,14 @@ interface StatsDataSource { * * @param siteId The WordPress.com site ID * @param quantity Number of data points to return + * @param unit Time unit: "day", "week", "month", "year" * @param date Optional date in YYYY-MM-DD format * @return Result containing subscriber count data */ suspend fun fetchStatsSubscribers( siteId: Long, quantity: Int = 1, + unit: String? = null, date: String? = null ): StatsSubscribersDataResult 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 ce96b84e3dff..3095de94ef42 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 @@ -996,10 +996,17 @@ class StatsDataSourceImpl @Inject constructor( override suspend fun fetchStatsSubscribers( siteId: Long, quantity: Int, + unit: String?, date: String? ): StatsSubscribersDataResult { + val subscribersUnit = when (unit) { + "week" -> StatsSubscribersUnit.WEEK + "month" -> StatsSubscribersUnit.MONTH + "year" -> StatsSubscribersUnit.YEAR + else -> StatsSubscribersUnit.DAY + } val params = StatsSubscribersParams( - unit = StatsSubscribersUnit.DAY, + unit = subscribersUnit, quantity = quantity.toUInt(), date = date, statFields = listOf( 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 fb918593509b..d9da48206aed 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -3,6 +3,7 @@ package org.wordpress.android.ui.newstats.repository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import org.wordpress.android.R import org.wordpress.android.ui.newstats.datasource.CityViewsDataResult import org.wordpress.android.ui.newstats.datasource.ClicksDataResult import org.wordpress.android.ui.newstats.datasource.CountryViewsDataResult @@ -1410,6 +1411,53 @@ class StatsRepository @Inject constructor( ) } + /** + * Fetches subscriber graph data for a given time unit. + */ + @Suppress("TooGenericExceptionCaught") + suspend fun fetchSubscribersGraph( + siteId: Long, + unit: String, + quantity: Int, + date: String + ): SubscribersGraphResult = withContext(ioDispatcher) { + try { + when ( + val result = statsDataSource + .fetchStatsSubscribers( + siteId, + quantity = quantity, + unit = unit, + date = date + ) + ) { + is StatsSubscribersDataResult.Success -> { + SubscribersGraphResult.Success( + dataPoints = + result.data.subscribersData.map { + SubscribersGraphDataPoint( + date = it.date, + count = it.count + ) + } + ) + } + is StatsSubscribersDataResult.Error -> { + SubscribersGraphResult.Error( + messageResId = + result.errorType.messageResId, + isAuthError = result.errorType == + StatsErrorType.AUTH_ERROR + ) + } + } + } catch (e: Exception) { + SubscribersGraphResult.Error( + messageResId = R.string.stats_error_api + ) + } + } + /** * Fetches a list of subscribers for the given site. */ @@ -1916,3 +1964,24 @@ data class EmailItemData( val opens: Long, val clicks: Long ) + +/** + * Result wrapper for subscribers graph fetch operation. + */ +sealed class SubscribersGraphResult { + data class Success( + val dataPoints: List + ) : SubscribersGraphResult() + data class Error( + @StringRes val messageResId: Int, + val isAuthError: Boolean = false + ) : SubscribersGraphResult() +} + +/** + * A single data point for the subscribers graph. + */ +data class SubscribersGraphDataPoint( + val date: String, + val count: Long +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt index 47b0a34bd9df..8610475a2bbd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt @@ -74,6 +74,10 @@ fun SubscribersTabContent( val allTimeUiState by allTimeViewModel.uiState.collectAsState() + val graphUiState by + graphViewModel.uiState.collectAsState() + val graphSelectedTab by + graphViewModel.selectedTab.collectAsState() val subscribersListUiState by subscribersListViewModel.uiState.collectAsState() val emailsUiState by @@ -299,6 +303,16 @@ fun SubscribersTabContent( SubscribersCardType .SUBSCRIBERS_GRAPH -> SubscribersGraphCard( + uiState = graphUiState, + selectedTab = + graphSelectedTab, + onTabSelected = { + graphViewModel + .onTabSelected(it) + }, + onRetry = { + graphViewModel.loadData() + }, onRemoveCard = { subscribersTabViewModel .removeCard(cardType) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt index 2bfa64e9322c..078fc3214147 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt @@ -1,29 +1,65 @@ package org.wordpress.android.ui.newstats.subscribers.subscribersgraph -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.marker.rememberDefaultCartesianMarker +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent +import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent +import com.patrykandpatrick.vico.compose.common.fill +import com.patrykandpatrick.vico.compose.common.shader.verticalGradient +import com.patrykandpatrick.vico.core.cartesian.CartesianDrawingContext +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.core.cartesian.data.lineSeries +import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker +import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker +import com.patrykandpatrick.vico.core.common.Insets +import com.patrykandpatrick.vico.core.common.component.LineComponent +import com.patrykandpatrick.vico.core.common.shader.ShaderProvider +import com.patrykandpatrick.vico.core.common.shape.CorneredShape import org.wordpress.android.R import org.wordpress.android.ui.newstats.components.CardPosition import org.wordpress.android.ui.newstats.components.StatsCardContainer +import org.wordpress.android.ui.newstats.components.StatsCardErrorContent import org.wordpress.android.ui.newstats.components.StatsCardHeader +import org.wordpress.android.ui.newstats.util.ShimmerBox +import org.wordpress.android.ui.newstats.util.formatStatValue private val CardPadding = 16.dp -private val PlaceholderHeight = 120.dp +private val ChartHeight = 160.dp @Suppress("LongParameterList") @Composable fun SubscribersGraphCard( + uiState: SubscribersGraphUiState, + selectedTab: SubscribersGraphTab, + onTabSelected: (SubscribersGraphTab) -> Unit, + onRetry: () -> Unit, onRemoveCard: () -> Unit, modifier: Modifier = Modifier, cardPosition: CardPosition? = null, @@ -33,38 +69,295 @@ fun SubscribersGraphCard( onMoveToBottom: (() -> Unit)? = null ) { StatsCardContainer(modifier = modifier) { - Column( + when (uiState) { + is SubscribersGraphUiState.Loading -> + LoadingContent( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + is SubscribersGraphUiState.Loaded -> + LoadedContent( + state = uiState, + selectedTab = selectedTab, + onTabSelected = onTabSelected, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + is SubscribersGraphUiState.Error -> + StatsCardErrorContent( + titleResId = + R.string.stats_subscribers_graph, + errorMessageResId = + R.string.stats_error_api, + onRetry = onRetry, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongParameterList") +@Composable +private fun LoadingContent( + selectedTab: SubscribersGraphTab, + onTabSelected: (SubscribersGraphTab) -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string.stats_subscribers_graph, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + PeriodTabs( + selectedTab = selectedTab, + onTabSelected = onTabSelected + ) + Spacer(modifier = Modifier.height(16.dp)) + ShimmerBox( modifier = Modifier .fillMaxWidth() - .padding(CardPadding) - ) { - StatsCardHeader( - titleResId = - R.string.stats_subscribers_graph, - onRemoveCard = onRemoveCard, - cardPosition = cardPosition, - onMoveUp = onMoveUp, - onMoveToTop = onMoveToTop, - onMoveDown = onMoveDown, - onMoveToBottom = onMoveToBottom + .height(ChartHeight) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongParameterList") +@Composable +private fun LoadedContent( + state: SubscribersGraphUiState.Loaded, + selectedTab: SubscribersGraphTab, + onTabSelected: (SubscribersGraphTab) -> Unit, + onRemoveCard: () -> Unit, + cardPosition: CardPosition?, + onMoveUp: (() -> Unit)?, + onMoveToTop: (() -> Unit)?, + onMoveDown: (() -> Unit)?, + onMoveToBottom: (() -> Unit)? +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(CardPadding) + ) { + StatsCardHeader( + titleResId = + R.string.stats_subscribers_graph, + onRemoveCard = onRemoveCard, + cardPosition = cardPosition, + onMoveUp = onMoveUp, + onMoveToTop = onMoveToTop, + onMoveDown = onMoveDown, + onMoveToBottom = onMoveToBottom + ) + Spacer(modifier = Modifier.height(12.dp)) + PeriodTabs( + selectedTab = selectedTab, + onTabSelected = onTabSelected + ) + Spacer(modifier = Modifier.height(16.dp)) + SubscribersChart( + dataPoints = state.dataPoints + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PeriodTabs( + selectedTab: SubscribersGraphTab, + onTabSelected: (SubscribersGraphTab) -> Unit +) { + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth() + ) { + SubscribersGraphTab.entries.forEachIndexed { + index, tab -> + SegmentedButton( + selected = tab == selectedTab, + onClick = { onTabSelected(tab) }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = + SubscribersGraphTab.entries.size + ), + label = { + Text( + text = stringResource( + tab.labelResId + ), + fontSize = 12.sp + ) + } ) - Spacer(modifier = Modifier.height(16.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .height(PlaceholderHeight), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource( - R.string.stats_no_data_yet - ), - style = MaterialTheme - .typography.bodyMedium, - color = MaterialTheme - .colorScheme.onSurfaceVariant + } + } +} + +@Composable +private fun SubscribersChart( + dataPoints: List +) { + if (dataPoints.isEmpty()) return + + val modelProducer = remember { + CartesianChartModelProducer() + } + + LaunchedEffect(dataPoints) { + modelProducer.runTransaction { + lineSeries { + series( + dataPoints.map { it.count.toInt() } ) } } } + + val primaryColor = MaterialTheme.colorScheme.primary + + val dateLabels = dataPoints.map { it.label } + val bottomAxisValueFormatter = + CartesianValueFormatter { _, value, _ -> + val index = value.toInt() + if (index in dateLabels.indices) { + dateLabels[index] + } else { + "" + } + } + + val markerValueFormatter = remember(dataPoints) { + SubscribersMarkerValueFormatter(dataPoints) + } + + val marker = rememberDefaultCartesianMarker( + label = rememberTextComponent( + color = + MaterialTheme.colorScheme.onSurface, + textSize = 12.sp, + lineCount = 2, + padding = Insets( + horizontalDp = 12f, + verticalDp = 8f + ), + background = rememberShapeComponent( + fill = fill( + MaterialTheme.colorScheme + .surfaceContainer + ), + shape = CorneredShape.rounded( + allPercent = 25 + ) + ) + ), + guideline = LineComponent( + fill = fill( + MaterialTheme.colorScheme.outline + .copy(alpha = 0.5f) + ), + thicknessDp = 1f + ), + valueFormatter = markerValueFormatter + ) + + val areaGradient = + ShaderProvider.verticalGradient( + colors = arrayOf( + primaryColor.copy(alpha = 0.8f), + primaryColor.copy(alpha = 0f) + ) + ) + + CartesianChartHost( + chart = rememberCartesianChart( + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider + .series( + LineCartesianLayer.Line( + fill = LineCartesianLayer + .LineFill.single( + fill(primaryColor) + ), + areaFill = + LineCartesianLayer + .AreaFill.single( + fill( + areaGradient + ) + ), + pointConnector = + LineCartesianLayer + .PointConnector + .cubic() + ) + ) + ), + startAxis = VerticalAxis.rememberStart( + line = null + ), + bottomAxis = HorizontalAxis.rememberBottom( + valueFormatter = + bottomAxisValueFormatter + ), + marker = marker + ), + modelProducer = modelProducer, + scrollState = rememberVicoScrollState( + scrollEnabled = false + ), + modifier = Modifier + .fillMaxWidth() + .height(ChartHeight) + ) +} + +private class SubscribersMarkerValueFormatter( + private val dataPoints: List +) : DefaultCartesianMarker.ValueFormatter { + override fun format( + context: CartesianDrawingContext, + targets: List + ): CharSequence { + val target = + targets.firstOrNull() ?: return "" + val x = target.x.toInt() + if (x !in dataPoints.indices) return "" + val point = dataPoints[x] + return "${point.label}\n" + + formatStatValue(point.count) + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt index d7825bca1e70..802037b17fb2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt @@ -1,9 +1,35 @@ package org.wordpress.android.ui.newstats.subscribers.subscribersgraph -/** - * UI state for the Subscribers Graph card. - * Currently a placeholder - will be populated later. - */ +import androidx.annotation.StringRes +import org.wordpress.android.R + sealed class SubscribersGraphUiState { - data object Placeholder : SubscribersGraphUiState() + data object Loading : SubscribersGraphUiState() + data class Loaded( + val dataPoints: List, + val selectedTab: SubscribersGraphTab + ) : SubscribersGraphUiState() + data class Error( + val message: String, + val isAuthError: Boolean = false + ) : SubscribersGraphUiState() +} + +data class GraphDataPoint( + val label: String, + val count: Long +) + +@Suppress("MagicNumber") +enum class SubscribersGraphTab( + val unit: String, + val quantity: Int, + @StringRes val labelResId: Int +) { + DAYS("day", 30, R.string.stats_subscribers_graph_days), + WEEKS("week", 12, R.string.stats_subscribers_graph_weeks), + MONTHS( + "month", 6, R.string.stats_subscribers_graph_months + ), + YEARS("year", 3, R.string.stats_subscribers_graph_years) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt index 382540a4fde8..703a39b4b016 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt @@ -1,36 +1,196 @@ package org.wordpress.android.ui.newstats.subscribers.subscribersgraph import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.SubscribersGraphResult +import org.wordpress.android.viewmodel.ResourceProvider +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale import javax.inject.Inject @HiltViewModel -class SubscribersGraphViewModel @Inject constructor() - : ViewModel() { - private val _uiState = MutableStateFlow< - SubscribersGraphUiState>( - SubscribersGraphUiState.Placeholder - ) +@Suppress("TooGenericExceptionCaught") +class SubscribersGraphViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private val _uiState = + MutableStateFlow( + SubscribersGraphUiState.Loading + ) val uiState: StateFlow = _uiState.asStateFlow() + private val _selectedTab = + MutableStateFlow(SubscribersGraphTab.DAYS) + val selectedTab: StateFlow = + _selectedTab.asStateFlow() + private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() - @Suppress("UnusedParameter") + private var isLoading = false + private var isLoadedSuccessfully = false + fun loadDataIfNeeded() { - // Placeholder - no data to load yet + if (isLoadedSuccessfully || isLoading) return + isLoading = true + loadData() } fun refresh() { - // Placeholder - no data to refresh yet + val site = + selectedSiteRepository.getSelectedSite() + ?: return + viewModelScope.launch { + try { + _isRefreshing.value = true + loadDataInternal(site.siteId) + } finally { + _isRefreshing.value = false + } + } } fun loadData() { - // Placeholder - no data to load yet + val site = + selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading = false + _uiState.value = SubscribersGraphUiState + .Error(message = "No site selected") + return + } + + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + isLoading = false + _uiState.value = SubscribersGraphUiState + .Error(message = "Not authenticated") + return + } + + statsRepository.init(accessToken) + _uiState.value = SubscribersGraphUiState.Loading + + viewModelScope.launch { + try { + loadDataInternal(site.siteId) + } finally { + isLoading = false + } + } + } + + fun onTabSelected(tab: SubscribersGraphTab) { + if (tab == _selectedTab.value) return + _selectedTab.value = tab + isLoadedSuccessfully = false + loadData() + } + + private suspend fun loadDataInternal(siteId: Long) { + try { + val tab = _selectedTab.value + val today = LocalDate.now() + val dateStr = today.format( + DateTimeFormatter.ISO_LOCAL_DATE + ) + when ( + val result = statsRepository + .fetchSubscribersGraph( + siteId, + unit = tab.unit, + quantity = tab.quantity, + date = dateStr + ) + ) { + is SubscribersGraphResult.Success -> { + isLoadedSuccessfully = true + val sorted = result.dataPoints + .sortedBy { it.date } + _uiState.value = + SubscribersGraphUiState.Loaded( + dataPoints = + sorted.map { + GraphDataPoint( + label = formatLabel( + it.date, tab + ), + count = it.count + ) + }, + selectedTab = tab + ) + } + is SubscribersGraphResult.Error -> { + _uiState.value = + SubscribersGraphUiState.Error( + message = resourceProvider + .getString( + result.messageResId + ), + isAuthError = + result.isAuthError + ) + } + } + } catch (e: Exception) { + _uiState.value = + SubscribersGraphUiState.Error( + message = e.message + ?: "Unknown error" + ) + } + } + + @Suppress("MagicNumber") + private fun formatLabel( + dateStr: String, + tab: SubscribersGraphTab + ): String { + return try { + val date = LocalDate.parse(dateStr) + when (tab) { + SubscribersGraphTab.DAYS -> { + val fmt = DateTimeFormatter.ofPattern( + "MMM d", Locale.getDefault() + ) + date.format(fmt) + } + SubscribersGraphTab.WEEKS -> { + val fmt = DateTimeFormatter.ofPattern( + "MMM d", Locale.getDefault() + ) + date.format(fmt) + } + SubscribersGraphTab.MONTHS -> { + val fmt = DateTimeFormatter.ofPattern( + "MMM", Locale.getDefault() + ) + date.format(fmt) + } + SubscribersGraphTab.YEARS -> { + val fmt = DateTimeFormatter.ofPattern( + "yyyy", Locale.getDefault() + ) + date.format(fmt) + } + } + } catch (_: Exception) { + dateStr + } } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index cad47105349b..47ea9d838ffb 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1632,6 +1632,10 @@ Latest emails Opens Clicks + Days + Weeks + Months + Years Remove Card diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt index 4154b67c961a..62f4ef15fa3d 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt @@ -44,7 +44,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given all calls succeed, when fetchSubscribersAllTime, then counts are extracted`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Success( StatsSubscribersData( @@ -71,7 +71,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given empty subscribers data, when fetchSubscribersAllTime, then counts are zero`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Success( StatsSubscribersData(subscribersData = emptyList()) @@ -87,7 +87,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given one call fails, when fetchSubscribersAllTime, then error is returned`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Error(StatsErrorType.API_ERROR) ) @@ -102,7 +102,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given auth error, when fetchSubscribersAllTime, then isAuthError is true`() = test { - whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull())) + whenever(statsDataSource.fetchStatsSubscribers(any(), any(), anyOrNull(), anyOrNull())) .thenReturn( StatsSubscribersDataResult.Error(StatsErrorType.AUTH_ERROR) ) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt index 6e0ba36fffce..5752966536c2 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt @@ -2,68 +2,328 @@ package org.wordpress.android.ui.newstats.subscribers.subscribersgraph import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat +import org.junit.Before import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.SubscribersGraphDataPoint +import org.wordpress.android.ui.newstats.repository.SubscribersGraphResult +import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi class SubscribersGraphViewModelTest : BaseUnitTest() { - private lateinit var viewModel: SubscribersGraphViewModel + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: + ResourceProvider + + private lateinit var viewModel: + SubscribersGraphViewModel + + 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( + resourceProvider.getString( + R.string.stats_error_api + ) + ).thenReturn(FAILED_TO_LOAD_ERROR) + } private fun initViewModel() { - viewModel = SubscribersGraphViewModel() + viewModel = SubscribersGraphViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadData() } @Test - fun `when initialized, then ui state is Placeholder`() = test { - initViewModel() + fun `when no site selected, then error state is emitted`() = + test { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) - assertThat(viewModel.uiState.value) - .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) - } + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + SubscribersGraphUiState.Error::class.java + ) + } @Test - fun `when initialized, then isRefreshing is false`() = test { - initViewModel() + fun `when access token is null, then error state is emitted`() = + test { + whenever(accountStore.accessToken) + .thenReturn(null) - assertThat(viewModel.isRefreshing.value).isFalse() - } + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + SubscribersGraphUiState.Error::class.java + ) + } @Test - fun `when loadDataIfNeeded is called, then state remains Placeholder`() = test { - initViewModel() + fun `when data loads successfully, then loaded state has data points`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) - viewModel.loadDataIfNeeded() + initViewModel() + advanceUntilIdle() - assertThat(viewModel.uiState.value) - .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) - } + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + SubscribersGraphUiState.Loaded::class.java + ) + val loaded = + state as SubscribersGraphUiState.Loaded + assertThat(loaded.dataPoints).hasSize(3) + assertThat(loaded.selectedTab) + .isEqualTo(SubscribersGraphTab.DAYS) + } @Test - fun `when refresh is called, then state remains Placeholder`() = test { - initViewModel() + fun `when fetch fails, then error state is emitted`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn( + SubscribersGraphResult.Error( + messageResId = R.string.stats_error_api + ) + ) - viewModel.refresh() + initViewModel() + advanceUntilIdle() - assertThat(viewModel.uiState.value) - .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) - } + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + SubscribersGraphUiState.Error::class.java + ) + assertThat( + (state as SubscribersGraphUiState.Error) + .message + ).isEqualTo(FAILED_TO_LOAD_ERROR) + } @Test - fun `when loadData is called, then state remains Placeholder`() = test { - initViewModel() + fun `when exception is thrown, then error state has message`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenThrow(RuntimeException("Test error")) - viewModel.loadData() + initViewModel() + advanceUntilIdle() - assertThat(viewModel.uiState.value) - .isInstanceOf(SubscribersGraphUiState.Placeholder::class.java) - } + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + SubscribersGraphUiState.Error::class.java + ) + assertThat( + (state as SubscribersGraphUiState.Error) + .message + ).isEqualTo("Test error") + } + + @Test + fun `when tab is selected, then data reloads with new tab`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onTabSelected( + SubscribersGraphTab.WEEKS + ) + advanceUntilIdle() + + assertThat(viewModel.selectedTab.value) + .isEqualTo(SubscribersGraphTab.WEEKS) + verify(statsRepository, times(2)) + .fetchSubscribersGraph( + any(), any(), any(), any() + ) + } + + @Test + fun `when same tab is selected, then data does not reload`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onTabSelected( + SubscribersGraphTab.DAYS + ) + advanceUntilIdle() + + verify(statsRepository, times(1)) + .fetchSubscribersGraph( + any(), any(), any(), any() + ) + } + + @Test + fun `when loadDataIfNeeded called twice, data loads once`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + viewModel = SubscribersGraphViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + verify(statsRepository, times(1)) + .fetchSubscribersGraph( + any(), any(), any(), any() + ) + } @Test - fun `when refresh is called, then isRefreshing remains false`() = test { - initViewModel() + fun `when refresh called, isRefreshing is false after`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value) + .isFalse() + } + + @Test + fun `when initialized, default tab is DAYS`() = + test { + viewModel = SubscribersGraphViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + + assertThat(viewModel.selectedTab.value) + .isEqualTo(SubscribersGraphTab.DAYS) + } + + @Test + fun `when data loads, correct unit param is used`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + verify(statsRepository) + .fetchSubscribersGraph( + eq(TEST_SITE_ID), + eq("day"), + eq(DAYS_QUANTITY), + any() + ) + } - viewModel.refresh() + private fun createSuccessResult() = + SubscribersGraphResult.Success( + dataPoints = listOf( + SubscribersGraphDataPoint( + "2026-02-25", TEST_COUNT_1 + ), + SubscribersGraphDataPoint( + "2026-02-26", TEST_COUNT_2 + ), + SubscribersGraphDataPoint( + "2026-02-27", TEST_COUNT_3 + ) + ) + ) - assertThat(viewModel.isRefreshing.value).isFalse() + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + private const val FAILED_TO_LOAD_ERROR = + "Failed to load stats" + private const val TEST_COUNT_1 = 100L + private const val TEST_COUNT_2 = 150L + private const val TEST_COUNT_3 = 200L + private const val DAYS_QUANTITY = 30 } } From 362ac33113ef3b9db397ba60016e2638e02e6f24 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 12:25:34 +0100 Subject: [PATCH 10/21] Add tests for Subscribers Graph and repository graph method Add 10 new ViewModel tests covering tab switching params, chronological sorting, empty data, auth errors, and edge cases. Add 4 repository tests for fetchSubscribersGraph covering success, empty, error, and auth error. Co-Authored-By: Claude Opus 4.6 --- .../StatsRepositorySubscribersTest.kt | 116 ++++++++++ .../SubscribersGraphViewModelTest.kt | 219 ++++++++++++++++++ 2 files changed, 335 insertions(+) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt index 62f4ef15fa3d..8e04be0c23a0 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt @@ -186,6 +186,122 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { } // endregion + // region fetchSubscribersGraph + @Test + fun `given success, when fetchSubscribersGraph, then data points are mapped`() = + test { + whenever( + statsDataSource.fetchStatsSubscribers( + any(), any(), anyOrNull(), anyOrNull() + ) + ).thenReturn( + StatsSubscribersDataResult.Success( + StatsSubscribersData( + subscribersData = listOf( + SubscribersDataPoint( + date = "2026-02-25", + count = 100L + ), + SubscribersDataPoint( + date = "2026-02-26", + count = 150L + ) + ) + ) + ) + ) + + val result = repository.fetchSubscribersGraph( + TEST_SITE_ID, "day", 30, "2026-02-27" + ) + + assertThat(result).isInstanceOf( + SubscribersGraphResult.Success::class.java + ) + val success = + result as SubscribersGraphResult.Success + assertThat(success.dataPoints).hasSize(2) + assertThat(success.dataPoints[0].date) + .isEqualTo("2026-02-25") + assertThat(success.dataPoints[0].count) + .isEqualTo(100L) + assertThat(success.dataPoints[1].count) + .isEqualTo(150L) + } + + @Test + fun `given empty data, when fetchSubscribersGraph, then empty list returned`() = + test { + whenever( + statsDataSource.fetchStatsSubscribers( + any(), any(), anyOrNull(), anyOrNull() + ) + ).thenReturn( + StatsSubscribersDataResult.Success( + StatsSubscribersData( + subscribersData = emptyList() + ) + ) + ) + + val result = repository.fetchSubscribersGraph( + TEST_SITE_ID, "day", 30, "2026-02-27" + ) + + val success = + result as SubscribersGraphResult.Success + assertThat(success.dataPoints).isEmpty() + } + + @Test + fun `given error, when fetchSubscribersGraph, then error result returned`() = + test { + whenever( + statsDataSource.fetchStatsSubscribers( + any(), any(), anyOrNull(), anyOrNull() + ) + ).thenReturn( + StatsSubscribersDataResult.Error( + StatsErrorType.API_ERROR + ) + ) + + val result = repository.fetchSubscribersGraph( + TEST_SITE_ID, "week", 12, "2026-02-27" + ) + + assertThat(result).isInstanceOf( + SubscribersGraphResult.Error::class.java + ) + val error = + result as SubscribersGraphResult.Error + assertThat(error.messageResId) + .isEqualTo(R.string.stats_error_api) + } + + @Test + fun `given auth error, when fetchSubscribersGraph, then isAuthError is true`() = + test { + whenever( + statsDataSource.fetchStatsSubscribers( + any(), any(), anyOrNull(), anyOrNull() + ) + ).thenReturn( + StatsSubscribersDataResult.Error( + StatsErrorType.AUTH_ERROR + ) + ) + + val result = repository.fetchSubscribersGraph( + TEST_SITE_ID, "month", 6, "2026-02-27" + ) + + val error = + result as SubscribersGraphResult.Error + assertThat(error.isAuthError).isTrue() + } + // endregion + // region fetchEmailsSummary @Test fun `given success, when fetchEmailsSummary, then items are mapped correctly`() = diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt index 5752966536c2..dfd96bea179d 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt @@ -279,6 +279,43 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { .isEqualTo(SubscribersGraphTab.DAYS) } + @Test + fun `when access token is empty, then error state is emitted`() = + test { + whenever(accountStore.accessToken) + .thenReturn("") + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + SubscribersGraphUiState.Error::class.java + ) + } + + @Test + fun `when exception with null message, then unknown error`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenThrow(RuntimeException()) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + SubscribersGraphUiState.Error::class.java + ) + assertThat( + (state as SubscribersGraphUiState.Error) + .message + ).isEqualTo("Unknown error") + } + @Test fun `when data loads, correct unit param is used`() = test { @@ -300,6 +337,185 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { ) } + @Test + fun `when weeks tab selected, correct params are used`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onTabSelected( + SubscribersGraphTab.WEEKS + ) + advanceUntilIdle() + + verify(statsRepository) + .fetchSubscribersGraph( + eq(TEST_SITE_ID), + eq("week"), + eq(WEEKS_QUANTITY), + any() + ) + } + + @Test + fun `when months tab selected, correct params are used`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onTabSelected( + SubscribersGraphTab.MONTHS + ) + advanceUntilIdle() + + verify(statsRepository) + .fetchSubscribersGraph( + eq(TEST_SITE_ID), + eq("month"), + eq(MONTHS_QUANTITY), + any() + ) + } + + @Test + fun `when years tab selected, correct params are used`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.onTabSelected( + SubscribersGraphTab.YEARS + ) + advanceUntilIdle() + + verify(statsRepository) + .fetchSubscribersGraph( + eq(TEST_SITE_ID), + eq("year"), + eq(YEARS_QUANTITY), + any() + ) + } + + @Test + fun `when data loads, points are sorted chronologically`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn( + SubscribersGraphResult.Success( + dataPoints = listOf( + SubscribersGraphDataPoint( + "2026-02-27", TEST_COUNT_3 + ), + SubscribersGraphDataPoint( + "2026-02-25", TEST_COUNT_1 + ), + SubscribersGraphDataPoint( + "2026-02-26", TEST_COUNT_2 + ) + ) + ) + ) + + initViewModel() + advanceUntilIdle() + + val loaded = viewModel.uiState.value + as SubscribersGraphUiState.Loaded + assertThat(loaded.dataPoints[0].count) + .isEqualTo(TEST_COUNT_1) + assertThat(loaded.dataPoints[1].count) + .isEqualTo(TEST_COUNT_2) + assertThat(loaded.dataPoints[2].count) + .isEqualTo(TEST_COUNT_3) + } + + @Test + fun `when data loads with empty list, then loaded state has no points`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn( + SubscribersGraphResult.Success( + dataPoints = emptyList() + ) + ) + + initViewModel() + advanceUntilIdle() + + val loaded = viewModel.uiState.value + as SubscribersGraphUiState.Loaded + assertThat(loaded.dataPoints).isEmpty() + } + + @Test + fun `when refresh called, data is fetched again`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + verify(statsRepository, times(2)) + .fetchSubscribersGraph( + any(), any(), any(), any() + ) + } + + @Test + fun `when error has auth error flag, then state reflects it`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn( + SubscribersGraphResult.Error( + messageResId = + R.string.stats_error_api, + isAuthError = true + ) + ) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + as SubscribersGraphUiState.Error + assertThat(state.isAuthError).isTrue() + } + private fun createSuccessResult() = SubscribersGraphResult.Success( dataPoints = listOf( @@ -325,5 +541,8 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { private const val TEST_COUNT_2 = 150L private const val TEST_COUNT_3 = 200L private const val DAYS_QUANTITY = 30 + private const val WEEKS_QUANTITY = 12 + private const val MONTHS_QUANTITY = 6 + private const val YEARS_QUANTITY = 3 } } From ef767a30f419085585c735e3daf6500041f9d5b4 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 13:24:40 +0100 Subject: [PATCH 11/21] Update wordpress-rs dependency and fix subscriber date formatting Update wordpress-rs to b17d6e02dde5ea55773d527c1cb6ad2f889fc90e, handle nullable dateSubscribed, format subscriber dates as relative time (e.g. "30 days", "1 year, 45 days") instead of raw date strings. Co-Authored-By: Claude Opus 4.6 --- .../datasource/StatsDataSourceImpl.kt | 7 ++- .../subscriberslist/SubscribersListCard.kt | 55 ++++++++++--------- .../ui/subscribers/SubscriberDetailScreen.kt | 5 +- .../ui/subscribers/SubscribersViewModel.kt | 5 +- gradle/libs.versions.toml | 2 +- 5 files changed, 43 insertions(+), 31 deletions(-) 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 3095de94ef42..7b9cac72129d 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 @@ -1089,7 +1089,12 @@ class StatsDataSourceImpl @Inject constructor( subscriber.displayName, subscribedSince = subscriber.dateSubscribed - .toString() + ?.let { + java.text.SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss", + java.util.Locale.US + ).format(it) + } ?: "" ) } ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt index 576413059e4f..b175894d9c18 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt @@ -245,37 +245,38 @@ internal fun formatSubscriberDate( dateString: String ): String { return try { - val inputFormat = - java.time.format.DateTimeFormatter - .ISO_DATE_TIME - val outputFormat = - java.time.format.DateTimeFormatter - .ofPattern( - "MMM d, yyyy", - java.util.Locale.getDefault() - ) - val dateTime = + val subscribed = try { java.time.LocalDateTime.parse( - dateString, inputFormat - ) - dateTime.format(outputFormat) - } catch (e: Exception) { - try { - val inputFormat = + dateString, java.time.format.DateTimeFormatter - .ISO_LOCAL_DATE - val outputFormat = + .ISO_DATE_TIME + ).toLocalDate() + } catch (_: Exception) { + java.time.LocalDate.parse( + dateString, java.time.format.DateTimeFormatter - .ofPattern( - "MMM d, yyyy", - java.util.Locale.getDefault() - ) - val date = java.time.LocalDate.parse( - dateString, inputFormat + .ISO_LOCAL_DATE ) - date.format(outputFormat) - } catch (e2: Exception) { - dateString } + val days = java.time.temporal.ChronoUnit.DAYS + .between(subscribed, java.time.LocalDate.now()) + when { + days < 1L -> "today" + days == 1L -> "1 day" + days < DAYS_IN_YEAR -> "$days days" + else -> { + val years = days / DAYS_IN_YEAR + val remaining = days % DAYS_IN_YEAR + val yearsPart = + if (years == 1L) "1 year" + else "$years years" + if (remaining == 0L) yearsPart + else "$yearsPart, $remaining days" + } + } + } catch (_: Exception) { + dateString } } + +private const val DAYS_IN_YEAR = 365L diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt index efc7e44e4ffb..37cd4b73918d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt @@ -231,7 +231,10 @@ fun NewsletterSubscriptionCard( DetailRow( label = stringResource(R.string.subscribers_date_label), - value = SimpleDateFormatWrapper().getDateInstance().format(subscriber.dateSubscribed) + value = subscriber.dateSubscribed?.let { + SimpleDateFormatWrapper().getDateInstance() + .format(it) + } ?: "" ) subscriber.subscriptionStatus?.let { status -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt index babf8906c9f9..1e8963cb93df 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt @@ -204,7 +204,10 @@ class SubscribersViewModel @Inject constructor( weight = .6f, ), DataViewItemField( - value = dateFormatWrapper.getDateInstance().format(subscriber.dateSubscribed), + value = subscriber.dateSubscribed?.let { + dateFormatWrapper.getDateInstance() + .format(it) + } ?: "", valueType = DataViewFieldType.DATE, weight = .4f, ), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1fdf5b4ee7c6..a6de99c10b02 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '1200-bb46874fa66d0ce72bda84220f616b577d05fd41' +wordpress-rs = '1200-b17d6e02dde5ea55773d527c1cb6ad2f889fc90e' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 8189f9ae5cba28ce5dd08fddbc1fa53f4cf81758 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 13:52:23 +0100 Subject: [PATCH 12/21] Address code review issues for Subscribers tab - Extract BaseSubscribersCardViewModel to eliminate ViewModel duplication - Replace hardcoded error strings with string resources - Use Android plural resources for formatSubscriberDate localization - Fix refresh() to call statsRepository.init() before fetching - Use AtomicBoolean for thread-safe isLoading/isLoadedSuccessfully flags - Lazy load detail items (fetch 5 for card, 100 on demand) - Prevent cardsToLoad from re-triggering on every config change - Extract shared formatEmailStat to StatsFormatter utility - Add FormatSubscriberDateTest with 12 tests covering all branches - Update existing ViewModel tests for base class refactor Co-Authored-By: Claude Opus 4.6 --- .../BaseSubscribersCardViewModel.kt | 130 ++++++++++++ .../subscribers/SubscribersTabContent.kt | 23 ++- .../subscribers/SubscribersTabViewModel.kt | 7 +- .../AllTimeSubscribersViewModel.kt | 154 ++++----------- .../newstats/subscribers/emails/EmailsCard.kt | 5 +- .../subscribers/emails/EmailsCardViewModel.kt | 173 ++++++---------- .../emails/EmailsDetailActivity.kt | 5 +- .../SubscribersGraphViewModel.kt | 185 ++++++------------ .../subscriberslist/SubscribersListCard.kt | 40 +++- .../SubscribersListDetailActivity.kt | 4 +- .../SubscribersListViewModel.kt | 179 ++++++----------- .../ui/newstats/util/StatsFormatter.kt | 7 + WordPress/src/main/res/values/plurals.xml | 11 ++ WordPress/src/main/res/values/strings.xml | 3 + .../SubscribersTabViewModelTest.kt | 25 +-- .../AllTimeSubscribersViewModelTest.kt | 6 + .../emails/EmailsCardViewModelTest.kt | 10 + .../SubscribersGraphViewModelTest.kt | 15 ++ .../FormatSubscriberDateTest.kt | 171 ++++++++++++++++ .../SubscribersListViewModelTest.kt | 10 + 20 files changed, 662 insertions(+), 501 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt new file mode 100644 index 000000000000..43314495d8f2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt @@ -0,0 +1,130 @@ +package org.wordpress.android.ui.newstats.subscribers + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider +import java.util.concurrent.atomic.AtomicBoolean + +@Suppress("TooGenericExceptionCaught") +abstract class BaseSubscribersCardViewModel( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + protected val statsRepository: StatsRepository, + protected val resourceProvider: ResourceProvider, + initialState: UiState +) : ViewModel() { + private val _uiState = MutableStateFlow(initialState) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private val isLoading = AtomicBoolean(false) + private val isLoadedSuccessfully = AtomicBoolean(false) + + protected abstract val loadingState: UiState + protected abstract fun errorState( + message: String, + isAuthError: Boolean = false + ): UiState + protected abstract suspend fun loadDataInternal(siteId: Long) + + fun loadDataIfNeeded() { + if (isLoadedSuccessfully.get() || + !isLoading.compareAndSet(false, true) + ) return + loadData() + } + + fun refresh() { + val site = selectedSiteRepository.getSelectedSite() + ?: return + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) return + statsRepository.init(accessToken) + viewModelScope.launch { + try { + _isRefreshing.value = true + fetchData(site.siteId) + } finally { + _isRefreshing.value = false + } + } + } + + fun loadData() { + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + isLoading.set(false) + updateState( + errorState( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ) + ) + return + } + + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) { + isLoading.set(false) + updateState( + errorState( + resourceProvider.getString( + R.string.stats_error_not_authenticated + ) + ) + ) + return + } + + statsRepository.init(accessToken) + updateState(loadingState) + + viewModelScope.launch { + try { + fetchData(site.siteId) + } finally { + isLoading.set(false) + } + } + } + + private suspend fun fetchData(siteId: Long) { + try { + loadDataInternal(siteId) + } catch (e: Exception) { + updateState( + errorState( + e.message ?: resourceProvider.getString( + R.string.stats_error_unknown + ) + ) + ) + } + } + + protected fun getSiteId(): Long? = + selectedSiteRepository.getSelectedSite()?.siteId + + protected fun updateState(state: UiState) { + _uiState.value = state + } + + protected fun markLoadedSuccessfully() { + isLoadedSuccessfully.set(true) + } + + protected fun resetLoadedSuccessfully() { + isLoadedSuccessfully.set(false) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt index 8610475a2bbd..f0fffc01139e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,6 +44,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.newstats.components.CardPosition import org.wordpress.android.ui.newstats.subscribers.alltimestats.AllTimeSubscribersCard @@ -71,6 +73,7 @@ fun SubscribersTabContent( viewModel() ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val allTimeUiState by allTimeViewModel.uiState.collectAsState() @@ -348,12 +351,13 @@ fun SubscribersTabContent( uiState = subscribersListUiState, onShowAllClick = { - SubscribersListDetailActivity - .start( - context, + scope.launch { + val items = subscribersListViewModel .getDetailData() - ) + SubscribersListDetailActivity + .start(context, items) + } }, onRetry = { subscribersListViewModel @@ -392,12 +396,15 @@ fun SubscribersTabContent( EmailsCard( uiState = emailsUiState, onShowAllClick = { - EmailsDetailActivity - .start( - context, + scope.launch { + val items = emailsViewModel .getDetailData() - ) + EmailsDetailActivity + .start( + context, items + ) + } }, onRetry = { emailsViewModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt index 15b35a56b937..02e7a0f93f42 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt @@ -41,6 +41,8 @@ class SubscribersTabViewModel @Inject constructor( val cardsToLoad: StateFlow> = _cardsToLoad.asStateFlow() + private var isInitialLoad = true + private val siteId: Long get() = selectedSiteRepository .getSelectedSite()?.siteId ?: 0L @@ -89,7 +91,10 @@ class SubscribersTabViewModel @Inject constructor( ) { _visibleCards.value = config.visibleCards _hiddenCards.value = config.hiddenCards() - _cardsToLoad.value = config.visibleCards + if (isInitialLoad) { + _cardsToLoad.value = config.visibleCards + isInitialLoad = false + } } fun removeCard(cardType: SubscribersCardType) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt index 697abdd5c378..e8d435510d41 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt @@ -1,128 +1,60 @@ package org.wordpress.android.ui.newstats.subscribers.alltimestats -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.SubscribersAllTimeResult +import org.wordpress.android.ui.newstats.subscribers.BaseSubscribersCardViewModel import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject @HiltViewModel class AllTimeSubscribersViewModel @Inject constructor( - private val selectedSiteRepository: SelectedSiteRepository, - private val accountStore: AccountStore, - private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider -) : ViewModel() { - private val _uiState = MutableStateFlow( - AllTimeSubscribersUiState.Loading - ) - val uiState: StateFlow = - _uiState.asStateFlow() - - private val _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = - _isRefreshing.asStateFlow() - - private var isLoading = false - private var isLoadedSuccessfully = false - - fun loadDataIfNeeded() { - if (isLoadedSuccessfully || isLoading) return - isLoading = true - loadData() - } - - fun refresh() { - val site = - selectedSiteRepository.getSelectedSite() - ?: return - viewModelScope.launch { - try { - _isRefreshing.value = true - loadDataInternal(site.siteId) - } finally { - _isRefreshing.value = false - } - } - } - - fun loadData() { - val site = - selectedSiteRepository.getSelectedSite() - if (site == null) { - isLoading = false - _uiState.value = AllTimeSubscribersUiState - .Error(message = "No site selected") - return - } - - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) { - isLoading = false - _uiState.value = AllTimeSubscribersUiState - .Error(message = "Not authenticated") - return - } - - statsRepository.init(accessToken) - _uiState.value = AllTimeSubscribersUiState.Loading - - viewModelScope.launch { - try { - loadDataInternal(site.siteId) - } finally { - isLoading = false - } - } - } - - @Suppress("TooGenericExceptionCaught") - private suspend fun loadDataInternal(siteId: Long) { - try { - when ( - val result = statsRepository - .fetchSubscribersAllTime(siteId) - ) { - is SubscribersAllTimeResult.Success -> { - isLoadedSuccessfully = true - _uiState.value = - AllTimeSubscribersUiState.Loaded( - currentCount = - result.currentCount, - count30DaysAgo = - result.count30DaysAgo, - count60DaysAgo = - result.count60DaysAgo, - count90DaysAgo = - result.count90DaysAgo - ) - } - is SubscribersAllTimeResult.Error -> { - _uiState.value = - AllTimeSubscribersUiState.Error( - message = resourceProvider - .getString( - result.messageResId - ), - isAuthError = - result.isAuthError - ) - } + selectedSiteRepository: SelectedSiteRepository, + accountStore: AccountStore, + statsRepository: StatsRepository, + resourceProvider: ResourceProvider +) : BaseSubscribersCardViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider, + AllTimeSubscribersUiState.Loading +) { + override val loadingState = AllTimeSubscribersUiState.Loading + + override fun errorState( + message: String, + isAuthError: Boolean + ) = AllTimeSubscribersUiState.Error(message, isAuthError) + + override suspend fun loadDataInternal(siteId: Long) { + when ( + val result = statsRepository + .fetchSubscribersAllTime(siteId) + ) { + is SubscribersAllTimeResult.Success -> { + markLoadedSuccessfully() + updateState( + AllTimeSubscribersUiState.Loaded( + currentCount = result.currentCount, + count30DaysAgo = result.count30DaysAgo, + count60DaysAgo = result.count60DaysAgo, + count90DaysAgo = result.count90DaysAgo + ) + ) } - } catch (e: Exception) { - _uiState.value = - AllTimeSubscribersUiState.Error( - message = e.message - ?: "Unknown error" + is SubscribersAllTimeResult.Error -> { + updateState( + AllTimeSubscribersUiState.Error( + message = resourceProvider.getString( + result.messageResId + ), + isAuthError = result.isAuthError + ) ) + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt index 519324abbb30..fd637c90ffdc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt @@ -27,6 +27,7 @@ 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.util.ShimmerBox +import org.wordpress.android.ui.newstats.util.formatEmailStat import org.wordpress.android.ui.newstats.util.formatStatValue private val CardPadding = 16.dp @@ -287,7 +288,3 @@ private fun EmailItemRow(item: EmailListItem) { ) } } - -private fun formatEmailStat(value: Long): String { - return if (value == 0L) "-" else formatStatValue(value) -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt index 23fea193ea3e..cba035cbce09 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt @@ -1,16 +1,11 @@ package org.wordpress.android.ui.newstats.subscribers.emails -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.EmailsStatsResult import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.subscribers.BaseSubscribersCardViewModel import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject @@ -19,118 +14,76 @@ private const val DETAIL_MAX_ITEMS = 100 @HiltViewModel class EmailsCardViewModel @Inject constructor( - private val selectedSiteRepository: SelectedSiteRepository, - private val accountStore: AccountStore, - private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider -) : ViewModel() { - private val _uiState = MutableStateFlow< - EmailsCardUiState>(EmailsCardUiState.Loading) - val uiState: StateFlow = - _uiState.asStateFlow() + selectedSiteRepository: SelectedSiteRepository, + accountStore: AccountStore, + statsRepository: StatsRepository, + resourceProvider: ResourceProvider +) : BaseSubscribersCardViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider, + EmailsCardUiState.Loading +) { + override val loadingState = EmailsCardUiState.Loading - private val _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = - _isRefreshing.asStateFlow() + override fun errorState( + message: String, + isAuthError: Boolean + ) = EmailsCardUiState.Error(message, isAuthError) - private var isLoading = false - private var isLoadedSuccessfully = false - private var allItems: List = - emptyList() - - fun loadDataIfNeeded() { - if (isLoadedSuccessfully || isLoading) return - isLoading = true - loadData() - } - - fun refresh() { - val site = - selectedSiteRepository.getSelectedSite() - ?: return - viewModelScope.launch { - try { - _isRefreshing.value = true - loadDataInternal(site.siteId) - } finally { - _isRefreshing.value = false - } + suspend fun getDetailData(): List { + val siteId = getSiteId() ?: return emptyList() + val result = statsRepository.fetchEmailsSummary( + siteId, DETAIL_MAX_ITEMS + ) + return when (result) { + is EmailsStatsResult.Success -> + result.items.map { + EmailListItem( + title = it.title, + opens = it.opens, + clicks = it.clicks + ) + } + is EmailsStatsResult.Error -> emptyList() } } - fun loadData() { - val site = - selectedSiteRepository.getSelectedSite() - if (site == null) { - isLoading = false - _uiState.value = EmailsCardUiState - .Error(message = "No site selected") - return - } - - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) { - isLoading = false - _uiState.value = EmailsCardUiState - .Error(message = "Not authenticated") - return - } - - statsRepository.init(accessToken) - _uiState.value = EmailsCardUiState.Loading - - viewModelScope.launch { - try { - loadDataInternal(site.siteId) - } finally { - isLoading = false + override suspend fun loadDataInternal(siteId: Long) { + when ( + val result = statsRepository + .fetchEmailsSummary( + siteId, CARD_MAX_ITEMS + ) + ) { + is EmailsStatsResult.Success -> { + markLoadedSuccessfully() + updateState( + EmailsCardUiState.Loaded( + items = result.items + .take(CARD_MAX_ITEMS) + .map { + EmailListItem( + title = it.title, + opens = it.opens, + clicks = it.clicks + ) + } + ) + ) } - } - } - - fun getDetailData(): List = allItems - - @Suppress("TooGenericExceptionCaught") - private suspend fun loadDataInternal(siteId: Long) { - try { - when ( - val result = statsRepository - .fetchEmailsSummary( - siteId, DETAIL_MAX_ITEMS + is EmailsStatsResult.Error -> { + updateState( + EmailsCardUiState.Error( + message = resourceProvider + .getString( + result.messageResId + ), + isAuthError = result.isAuthError ) - ) { - is EmailsStatsResult.Success -> { - isLoadedSuccessfully = true - allItems = result.items.map { - EmailListItem( - title = it.title, - opens = it.opens, - clicks = it.clicks - ) - } - _uiState.value = - EmailsCardUiState.Loaded( - items = allItems.take( - CARD_MAX_ITEMS - ) - ) - } - is EmailsStatsResult.Error -> { - _uiState.value = - EmailsCardUiState.Error( - message = resourceProvider - .getString( - result.messageResId - ), - isAuthError = - result.isAuthError - ) - } + ) } - } catch (e: Exception) { - _uiState.value = EmailsCardUiState.Error( - message = e.message ?: "Unknown error" - ) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt index f31fb9c8dd95..24e8637c7377 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt @@ -35,6 +35,7 @@ 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.util.formatEmailStat import org.wordpress.android.ui.newstats.util.formatStatValue import org.wordpress.android.util.extensions.getParcelableArrayListCompat @@ -230,7 +231,3 @@ private fun DetailEmailRow(item: EmailListItem) { ) } } - -private fun formatEmailStat(value: Long): String { - return if (value == 0L) "-" else formatStatValue(value) -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt index 703a39b4b016..7d603edd2a2e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt @@ -1,16 +1,14 @@ package org.wordpress.android.ui.newstats.subscribers.subscribersgraph -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.SubscribersGraphResult +import org.wordpress.android.ui.newstats.subscribers.BaseSubscribersCardViewModel import org.wordpress.android.viewmodel.ResourceProvider import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -18,141 +16,79 @@ import java.util.Locale import javax.inject.Inject @HiltViewModel -@Suppress("TooGenericExceptionCaught") class SubscribersGraphViewModel @Inject constructor( - private val selectedSiteRepository: SelectedSiteRepository, - private val accountStore: AccountStore, - private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider -) : ViewModel() { - private val _uiState = - MutableStateFlow( - SubscribersGraphUiState.Loading - ) - val uiState: StateFlow = - _uiState.asStateFlow() - + selectedSiteRepository: SelectedSiteRepository, + accountStore: AccountStore, + statsRepository: StatsRepository, + resourceProvider: ResourceProvider +) : BaseSubscribersCardViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider, + SubscribersGraphUiState.Loading +) { private val _selectedTab = MutableStateFlow(SubscribersGraphTab.DAYS) val selectedTab: StateFlow = _selectedTab.asStateFlow() - private val _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = - _isRefreshing.asStateFlow() - - private var isLoading = false - private var isLoadedSuccessfully = false - - fun loadDataIfNeeded() { - if (isLoadedSuccessfully || isLoading) return - isLoading = true - loadData() - } - - fun refresh() { - val site = - selectedSiteRepository.getSelectedSite() - ?: return - viewModelScope.launch { - try { - _isRefreshing.value = true - loadDataInternal(site.siteId) - } finally { - _isRefreshing.value = false - } - } - } - - fun loadData() { - val site = - selectedSiteRepository.getSelectedSite() - if (site == null) { - isLoading = false - _uiState.value = SubscribersGraphUiState - .Error(message = "No site selected") - return - } - - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) { - isLoading = false - _uiState.value = SubscribersGraphUiState - .Error(message = "Not authenticated") - return - } - - statsRepository.init(accessToken) - _uiState.value = SubscribersGraphUiState.Loading + override val loadingState = SubscribersGraphUiState.Loading - viewModelScope.launch { - try { - loadDataInternal(site.siteId) - } finally { - isLoading = false - } - } - } + override fun errorState( + message: String, + isAuthError: Boolean + ) = SubscribersGraphUiState.Error(message, isAuthError) fun onTabSelected(tab: SubscribersGraphTab) { if (tab == _selectedTab.value) return _selectedTab.value = tab - isLoadedSuccessfully = false + resetLoadedSuccessfully() loadData() } - private suspend fun loadDataInternal(siteId: Long) { - try { - val tab = _selectedTab.value - val today = LocalDate.now() - val dateStr = today.format( - DateTimeFormatter.ISO_LOCAL_DATE - ) - when ( - val result = statsRepository - .fetchSubscribersGraph( - siteId, - unit = tab.unit, - quantity = tab.quantity, - date = dateStr - ) - ) { - is SubscribersGraphResult.Success -> { - isLoadedSuccessfully = true - val sorted = result.dataPoints - .sortedBy { it.date } - _uiState.value = - SubscribersGraphUiState.Loaded( - dataPoints = - sorted.map { - GraphDataPoint( - label = formatLabel( - it.date, tab - ), - count = it.count - ) - }, - selectedTab = tab - ) - } - is SubscribersGraphResult.Error -> { - _uiState.value = - SubscribersGraphUiState.Error( - message = resourceProvider - .getString( - result.messageResId + override suspend fun loadDataInternal(siteId: Long) { + val tab = _selectedTab.value + val today = LocalDate.now() + val dateStr = today.format( + DateTimeFormatter.ISO_LOCAL_DATE + ) + when ( + val result = statsRepository + .fetchSubscribersGraph( + siteId, + unit = tab.unit, + quantity = tab.quantity, + date = dateStr + ) + ) { + is SubscribersGraphResult.Success -> { + markLoadedSuccessfully() + val sorted = result.dataPoints + .sortedBy { it.date } + updateState( + SubscribersGraphUiState.Loaded( + dataPoints = sorted.map { + GraphDataPoint( + label = formatLabel( + it.date, tab ), - isAuthError = - result.isAuthError - ) - } + count = it.count + ) + }, + selectedTab = tab + ) + ) } - } catch (e: Exception) { - _uiState.value = - SubscribersGraphUiState.Error( - message = e.message - ?: "Unknown error" + is SubscribersGraphResult.Error -> { + updateState( + SubscribersGraphUiState.Error( + message = resourceProvider + .getString(result.messageResId), + isAuthError = result.isAuthError + ) ) + } } } @@ -164,12 +100,7 @@ class SubscribersGraphViewModel @Inject constructor( return try { val date = LocalDate.parse(dateStr) when (tab) { - SubscribersGraphTab.DAYS -> { - val fmt = DateTimeFormatter.ofPattern( - "MMM d", Locale.getDefault() - ) - date.format(fmt) - } + SubscribersGraphTab.DAYS, SubscribersGraphTab.WEEKS -> { val fmt = DateTimeFormatter.ofPattern( "MMM d", Locale.getDefault() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt index b175894d9c18..ab443438daca 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -230,7 +231,8 @@ private fun SubscriberItemRow( Spacer(modifier = Modifier.width(12.dp)) Text( text = formatSubscriberDate( - item.subscribedSince + item.subscribedSince, + LocalContext.current.resources ), style = MaterialTheme .typography.bodySmall, @@ -242,7 +244,8 @@ private fun SubscriberItemRow( @Suppress("TooGenericExceptionCaught", "SwallowedException") internal fun formatSubscriberDate( - dateString: String + dateString: String, + resources: android.content.res.Resources ): String { return try { val subscribed = try { @@ -261,17 +264,36 @@ internal fun formatSubscriberDate( val days = java.time.temporal.ChronoUnit.DAYS .between(subscribed, java.time.LocalDate.now()) when { - days < 1L -> "today" - days == 1L -> "1 day" - days < DAYS_IN_YEAR -> "$days days" + days < 1L -> resources.getString( + R.string.stats_subscriber_since_today + ) + days < DAYS_IN_YEAR -> resources + .getQuantityString( + R.plurals.stats_subscriber_days, + days.toInt(), days.toInt() + ) else -> { val years = days / DAYS_IN_YEAR val remaining = days % DAYS_IN_YEAR - val yearsPart = - if (years == 1L) "1 year" - else "$years years" + val yearsPart = resources + .getQuantityString( + R.plurals.stats_subscriber_years, + years.toInt(), years.toInt() + ) if (remaining == 0L) yearsPart - else "$yearsPart, $remaining days" + else { + val daysPart = resources + .getQuantityString( + R.plurals.stats_subscriber_days, + remaining.toInt(), + remaining.toInt() + ) + resources.getString( + R.string + .stats_subscriber_years_and_days, + yearsPart, daysPart + ) + } } } } catch (_: Exception) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt index 66ddf639b057..21fa93d8b66d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -185,7 +186,8 @@ private fun DetailSubscriberRow( Spacer(modifier = Modifier.width(12.dp)) Text( text = formatSubscriberDate( - item.subscribedSince + item.subscribedSince, + LocalContext.current.resources ), style = MaterialTheme .typography.bodySmall, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt index de1c8d351f7f..39f5f18f526b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt @@ -1,16 +1,11 @@ package org.wordpress.android.ui.newstats.subscribers.subscriberslist -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.SubscribersListResult +import org.wordpress.android.ui.newstats.subscribers.BaseSubscribersCardViewModel import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject @@ -19,123 +14,77 @@ private const val DETAIL_MAX_ITEMS = 100 @HiltViewModel class SubscribersListViewModel @Inject constructor( - private val selectedSiteRepository: SelectedSiteRepository, - private val accountStore: AccountStore, - private val statsRepository: StatsRepository, - private val resourceProvider: ResourceProvider -) : ViewModel() { - private val _uiState = MutableStateFlow< - SubscribersListUiState>( - SubscribersListUiState.Loading - ) - val uiState: StateFlow = - _uiState.asStateFlow() + selectedSiteRepository: SelectedSiteRepository, + accountStore: AccountStore, + statsRepository: StatsRepository, + resourceProvider: ResourceProvider +) : BaseSubscribersCardViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider, + SubscribersListUiState.Loading +) { + override val loadingState = SubscribersListUiState.Loading - private val _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = - _isRefreshing.asStateFlow() + override fun errorState( + message: String, + isAuthError: Boolean + ) = SubscribersListUiState.Error(message, isAuthError) - private var isLoading = false - private var isLoadedSuccessfully = false - private var allItems: List = - emptyList() - - fun loadDataIfNeeded() { - if (isLoadedSuccessfully || isLoading) return - isLoading = true - loadData() - } - - fun refresh() { - val site = - selectedSiteRepository.getSelectedSite() - ?: return - viewModelScope.launch { - try { - _isRefreshing.value = true - loadDataInternal(site.siteId) - } finally { - _isRefreshing.value = false - } - } - } - - fun loadData() { - val site = - selectedSiteRepository.getSelectedSite() - if (site == null) { - isLoading = false - _uiState.value = SubscribersListUiState - .Error(message = "No site selected") - return - } - - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) { - isLoading = false - _uiState.value = SubscribersListUiState - .Error(message = "Not authenticated") - return - } - - statsRepository.init(accessToken) - _uiState.value = SubscribersListUiState.Loading - - viewModelScope.launch { - try { - loadDataInternal(site.siteId) - } finally { - isLoading = false - } + suspend fun getDetailData(): List { + val siteId = getSiteId() ?: return emptyList() + val result = statsRepository.fetchSubscribersList( + siteId, DETAIL_MAX_ITEMS + ) + return when (result) { + is SubscribersListResult.Success -> + result.subscribers.map { + SubscriberListItem( + displayName = it.displayName, + subscribedSince = + it.subscribedSince + ) + } + is SubscribersListResult.Error -> emptyList() } } - fun getDetailData(): List = - allItems - - @Suppress("TooGenericExceptionCaught") - private suspend fun loadDataInternal(siteId: Long) { - try { - when ( - val result = statsRepository - .fetchSubscribersList( - siteId, DETAIL_MAX_ITEMS + override suspend fun loadDataInternal(siteId: Long) { + when ( + val result = statsRepository + .fetchSubscribersList( + siteId, CARD_MAX_ITEMS + ) + ) { + is SubscribersListResult.Success -> { + markLoadedSuccessfully() + updateState( + SubscribersListUiState.Loaded( + items = result.subscribers + .take(CARD_MAX_ITEMS) + .map { + SubscriberListItem( + displayName = + it.displayName, + subscribedSince = + it.subscribedSince + ) + } ) - ) { - is SubscribersListResult.Success -> { - isLoadedSuccessfully = true - allItems = result.subscribers.map { - SubscriberListItem( - displayName = it.displayName, - subscribedSince = - it.subscribedSince - ) - } - _uiState.value = - SubscribersListUiState.Loaded( - items = allItems.take( - CARD_MAX_ITEMS - ) - ) - } - is SubscribersListResult.Error -> { - _uiState.value = - SubscribersListUiState.Error( - message = resourceProvider - .getString( - result.messageResId - ), - isAuthError = - result.isAuthError - ) - } + ) } - } catch (e: Exception) { - _uiState.value = SubscribersListUiState - .Error( - message = e.message - ?: "Unknown error" + is SubscribersListResult.Error -> { + updateState( + SubscribersListUiState.Error( + message = resourceProvider + .getString( + result.messageResId + ), + isAuthError = result.isAuthError + ) ) + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt index 85278c7e2d56..02baf6b635c2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/util/StatsFormatter.kt @@ -23,6 +23,13 @@ fun formatStatValue(value: Long): String { } } +/** + * Formats an email stat value for display, showing "-" for zero values. + */ +fun formatEmailStat(value: Long): String { + return if (value == 0L) "-" else formatStatValue(value) +} + /** * Converts a StatsPeriod to a human-readable date range string. */ diff --git a/WordPress/src/main/res/values/plurals.xml b/WordPress/src/main/res/values/plurals.xml index 8c69cd6d7730..775d07268758 100644 --- a/WordPress/src/main/res/values/plurals.xml +++ b/WordPress/src/main/res/values/plurals.xml @@ -20,4 +20,15 @@ %1$d week ago %1$d weeks ago + + + + %1$d day + %1$d days + + + + %1$d year + %1$d years + diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 47ea9d838ffb..c7e91a8717e1 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1573,6 +1573,7 @@ Failed to load stats An unexpected error occurred You don\'t have permission to view these stats + Not authenticated Network error. Please check your connection and try again There was a problem loading the data. Please try again Unable to open WP Admin for this site @@ -1636,6 +1637,8 @@ Weeks Months Years + today + %1$s, %2$s Remove Card diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt index f6891e3d59b3..4563c1998e03 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt @@ -272,19 +272,22 @@ class SubscribersTabViewModelTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `when configuration changes via flow, then cardsToLoad is updated`() = test { - initViewModel() - advanceUntilIdle() + fun `when configuration changes via flow, then cardsToLoad is not updated after initial load`() = + test { + initViewModel() + advanceUntilIdle() - val newConfig = SubscribersCardsConfiguration( - visibleCards = listOf(SubscribersCardType.SUBSCRIBERS_LIST) - ) - configurationFlow.value = TEST_SITE_ID to newConfig - advanceUntilIdle() + val initialCardsToLoad = viewModel.cardsToLoad.value - assertThat(viewModel.cardsToLoad.value) - .containsExactly(SubscribersCardType.SUBSCRIBERS_LIST) - } + val newConfig = SubscribersCardsConfiguration( + visibleCards = listOf(SubscribersCardType.SUBSCRIBERS_LIST) + ) + configurationFlow.value = TEST_SITE_ID to newConfig + advanceUntilIdle() + + assertThat(viewModel.cardsToLoad.value) + .isEqualTo(initialCardsToLoad) + } // endregion // region Network availability tests diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt index 83879f8f8162..96554409f6a4 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt @@ -47,6 +47,12 @@ class AllTimeSubscribersViewModelTest : BaseUnitTest() { whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) whenever(resourceProvider.getString(R.string.stats_error_api)) .thenReturn(FAILED_TO_LOAD_ERROR) + whenever(resourceProvider.getString(R.string.stats_error_no_site)) + .thenReturn("No site selected") + whenever(resourceProvider.getString(R.string.stats_error_not_authenticated)) + .thenReturn("Not authenticated") + whenever(resourceProvider.getString(R.string.stats_error_unknown)) + .thenReturn("Unknown error") } private fun initViewModel() { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt index 9a4b5e40f483..c96c33861f05 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt @@ -4,7 +4,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.times @@ -21,6 +23,7 @@ import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) class EmailsCardViewModelTest : BaseUnitTest() { @Mock private lateinit var selectedSiteRepository: SelectedSiteRepository @@ -48,6 +51,13 @@ class EmailsCardViewModelTest : BaseUnitTest() { whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) whenever(resourceProvider.getString(R.string.stats_error_api)) .thenReturn(FAILED_TO_LOAD_ERROR) + whenever(resourceProvider.getString(R.string.stats_error_no_site)) + .thenReturn("No site selected") + whenever( + resourceProvider.getString(R.string.stats_error_not_authenticated) + ).thenReturn("Not authenticated") + whenever(resourceProvider.getString(R.string.stats_error_unknown)) + .thenReturn("Unknown error") } private fun initViewModel() { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt index dfd96bea179d..5cc77abb9b3c 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt @@ -57,6 +57,21 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { R.string.stats_error_api ) ).thenReturn(FAILED_TO_LOAD_ERROR) + whenever( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn("No site selected") + whenever( + resourceProvider.getString( + R.string.stats_error_not_authenticated + ) + ).thenReturn("Not authenticated") + whenever( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn("Unknown error") } private fun initViewModel() { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt new file mode 100644 index 000000000000..d90e002a2d2b --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt @@ -0,0 +1,171 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +import android.content.res.Resources +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.wordpress.android.R +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@RunWith(MockitoJUnitRunner::class) +class FormatSubscriberDateTest { + @Mock + private lateinit var resources: Resources + + @Before + fun setUp() { + whenever( + resources.getString( + R.string.stats_subscriber_since_today + ) + ).thenReturn("today") + + whenever( + resources.getQuantityString( + eq(R.plurals.stats_subscriber_days), + any(), any() + ) + ).thenAnswer { invocation -> + val count = invocation.getArgument(1) + if (count == 1) "$count day" else "$count days" + } + + whenever( + resources.getQuantityString( + eq(R.plurals.stats_subscriber_years), + any(), any() + ) + ).thenAnswer { invocation -> + val count = invocation.getArgument(1) + if (count == 1) "$count year" + else "$count years" + } + + whenever( + resources.getString( + eq( + R.string + .stats_subscriber_years_and_days + ), + any(), any() + ) + ).thenAnswer { invocation -> + val years = invocation.getArgument(1) + val days = invocation.getArgument(2) + "$years, $days" + } + } + + private fun dateNDaysAgo(n: Long): String = + LocalDate.now().minusDays(n) + .format(DateTimeFormatter.ISO_LOCAL_DATE) + + private fun dateTimeNDaysAgo(n: Long): String = + LocalDate.now().minusDays(n) + .atStartOfDay() + .format( + DateTimeFormatter.ISO_LOCAL_DATE_TIME + ) + + @Test + fun `when subscribed today, then returns today`() { + val result = formatSubscriberDate( + dateNDaysAgo(0), resources + ) + assertThat(result).isEqualTo("today") + } + + @Test + fun `when subscribed 1 day ago, then returns 1 day`() { + val result = formatSubscriberDate( + dateNDaysAgo(1), resources + ) + assertThat(result).isEqualTo("1 day") + } + + @Test + fun `when subscribed 30 days ago, then returns 30 days`() { + val result = formatSubscriberDate( + dateNDaysAgo(30), resources + ) + assertThat(result).isEqualTo("30 days") + } + + @Test + fun `when subscribed 364 days ago, then returns 364 days`() { + val result = formatSubscriberDate( + dateNDaysAgo(364), resources + ) + assertThat(result).isEqualTo("364 days") + } + + @Test + fun `when subscribed exactly 1 year ago, then returns 1 year`() { + val result = formatSubscriberDate( + dateNDaysAgo(365), resources + ) + assertThat(result).isEqualTo("1 year") + } + + @Test + fun `when subscribed 1 year and 1 day ago, then returns years and days`() { + val result = formatSubscriberDate( + dateNDaysAgo(366), resources + ) + assertThat(result).isEqualTo("1 year, 1 day") + } + + @Test + fun `when subscribed 2 years ago, then returns 2 years`() { + val result = formatSubscriberDate( + dateNDaysAgo(730), resources + ) + assertThat(result).isEqualTo("2 years") + } + + @Test + fun `when subscribed 2 years and 100 days ago, then returns years and days`() { + val result = formatSubscriberDate( + dateNDaysAgo(830), resources + ) + assertThat(result) + .isEqualTo("2 years, 100 days") + } + + @Test + fun `when date is ISO datetime format, then parses correctly`() { + val result = formatSubscriberDate( + dateTimeNDaysAgo(10), resources + ) + assertThat(result).isEqualTo("10 days") + } + + @Test + fun `when date is ISO date format, then parses correctly`() { + val result = formatSubscriberDate( + dateNDaysAgo(10), resources + ) + assertThat(result).isEqualTo("10 days") + } + + @Test + fun `when date string is invalid, then returns original string`() { + val result = formatSubscriberDate( + "not-a-date", resources + ) + assertThat(result).isEqualTo("not-a-date") + } + + @Test + fun `when date string is empty, then returns empty string`() { + val result = formatSubscriberDate("", resources) + assertThat(result).isEqualTo("") + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt index 9c87368ece52..1b9baef26d36 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt @@ -4,7 +4,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.times @@ -21,6 +23,7 @@ import org.wordpress.android.ui.newstats.repository.SubscribersListResult import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) class SubscribersListViewModelTest : BaseUnitTest() { @Mock private lateinit var selectedSiteRepository: SelectedSiteRepository @@ -48,6 +51,13 @@ class SubscribersListViewModelTest : BaseUnitTest() { whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) whenever(resourceProvider.getString(R.string.stats_error_api)) .thenReturn(FAILED_TO_LOAD_ERROR) + whenever(resourceProvider.getString(R.string.stats_error_no_site)) + .thenReturn("No site selected") + whenever( + resourceProvider.getString(R.string.stats_error_not_authenticated) + ).thenReturn("Not authenticated") + whenever(resourceProvider.getString(R.string.stats_error_unknown)) + .thenReturn("Unknown error") } private fun initViewModel() { From f67d4598c3e5db9d20c3dec97d12d4562454a09e Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 14:08:49 +0100 Subject: [PATCH 13/21] Add pagination to Subscribers and Emails detail pages Detail pages now fetch data via their own ViewModels with infinite scroll instead of receiving all items through Intent extras. Subscribers uses page-based pagination; Emails uses increasing quantity. Both use mutex- guarded loading with scroll-to-end detection. Co-Authored-By: Claude Opus 4.6 --- .../ui/newstats/datasource/StatsDataSource.kt | 4 +- .../datasource/StatsDataSourceImpl.kt | 5 +- .../ui/newstats/repository/StatsRepository.kt | 7 +- .../subscribers/SubscribersTabContent.kt | 23 +- .../subscribers/emails/EmailsCardViewModel.kt | 19 -- .../emails/EmailsDetailActivity.kt | 142 +++++--- .../emails/EmailsDetailViewModel.kt | 117 +++++++ .../SubscribersListDetailActivity.kt | 151 ++++++--- .../SubscribersListDetailViewModel.kt | 120 +++++++ .../SubscribersListViewModel.kt | 19 -- .../StatsRepositorySubscribersTest.kt | 23 +- .../emails/EmailsCardViewModelTest.kt | 18 - .../emails/EmailsDetailViewModelTest.kt | 310 +++++++++++++++++ .../SubscribersListDetailViewModelTest.kt | 315 ++++++++++++++++++ .../SubscribersListViewModelTest.kt | 39 +-- 15 files changed, 1119 insertions(+), 193 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt 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 bc155ef2f367..1e9ec4b56925 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 @@ -229,11 +229,13 @@ interface StatsDataSource { * * @param siteId The WordPress.com site ID * @param perPage Number of subscribers per page + * @param page Page number (1-based) * @return Result containing subscriber items or an error */ suspend fun fetchSubscribersByUserType( siteId: Long, - perPage: Int = 10 + perPage: Int = 10, + page: Int = 1 ): SubscribersByUserTypeDataResult /** 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 7b9cac72129d..266f3763b2a3 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 @@ -1053,12 +1053,13 @@ class StatsDataSourceImpl @Inject constructor( override suspend fun fetchSubscribersByUserType( siteId: Long, - perPage: Int + perPage: Int, + page: Int ): SubscribersByUserTypeDataResult { val params = SubscribersByUserTypeParams( userType = SubscribersByUserTypeUserType.WP_COM, perPage = perPage.toULong(), - page = 1uL, + page = page.toULong(), sort = SubscribersByUserTypeSortField .DATE_SUBSCRIBED ) 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 d9da48206aed..03190010f2df 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 @@ -1463,11 +1463,14 @@ class StatsRepository @Inject constructor( */ suspend fun fetchSubscribersList( siteId: Long, - perPage: Int = SUBSCRIBERS_DEFAULT_MAX + perPage: Int = SUBSCRIBERS_DEFAULT_MAX, + page: Int = 1 ): SubscribersListResult = withContext(ioDispatcher) { when ( val result = statsDataSource - .fetchSubscribersByUserType(siteId, perPage) + .fetchSubscribersByUserType( + siteId, perPage, page + ) ) { is SubscribersByUserTypeDataResult.Success -> { SubscribersListResult.Success( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt index f0fffc01139e..78ae29dc5fe2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,7 +43,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.ui.newstats.components.CardPosition import org.wordpress.android.ui.newstats.subscribers.alltimestats.AllTimeSubscribersCard @@ -73,7 +71,6 @@ fun SubscribersTabContent( viewModel() ) { val context = LocalContext.current - val scope = rememberCoroutineScope() val allTimeUiState by allTimeViewModel.uiState.collectAsState() @@ -351,13 +348,8 @@ fun SubscribersTabContent( uiState = subscribersListUiState, onShowAllClick = { - scope.launch { - val items = - subscribersListViewModel - .getDetailData() - SubscribersListDetailActivity - .start(context, items) - } + SubscribersListDetailActivity + .start(context) }, onRetry = { subscribersListViewModel @@ -396,15 +388,8 @@ fun SubscribersTabContent( EmailsCard( uiState = emailsUiState, onShowAllClick = { - scope.launch { - val items = - emailsViewModel - .getDetailData() - EmailsDetailActivity - .start( - context, items - ) - } + EmailsDetailActivity + .start(context) }, onRetry = { emailsViewModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt index cba035cbce09..fc0d49427aa5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt @@ -10,7 +10,6 @@ import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject private const val CARD_MAX_ITEMS = 5 -private const val DETAIL_MAX_ITEMS = 100 @HiltViewModel class EmailsCardViewModel @Inject constructor( @@ -32,24 +31,6 @@ class EmailsCardViewModel @Inject constructor( isAuthError: Boolean ) = EmailsCardUiState.Error(message, isAuthError) - suspend fun getDetailData(): List { - val siteId = getSiteId() ?: return emptyList() - val result = statsRepository.fetchEmailsSummary( - siteId, DETAIL_MAX_ITEMS - ) - return when (result) { - is EmailsStatsResult.Success -> - result.items.map { - EmailListItem( - title = it.title, - opens = it.opens, - clicks = it.clicks - ) - } - is EmailsStatsResult.Error -> emptyList() - } - } - override suspend fun loadDataInternal(siteId: Long) { when ( val result = statsRepository diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt index 24e8637c7377..6cc16743f4a6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt @@ -4,17 +4,21 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -24,6 +28,11 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -31,30 +40,22 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel 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.util.formatEmailStat -import org.wordpress.android.ui.newstats.util.formatStatValue -import org.wordpress.android.util.extensions.getParcelableArrayListCompat -private const val EXTRA_ITEMS = "extra_items" +private const val LOAD_MORE_THRESHOLD = 5 @AndroidEntryPoint class EmailsDetailActivity : BaseAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - val items = intent.extras - ?.getParcelableArrayListCompat( - EXTRA_ITEMS - ) ?: arrayListOf() - setContent { AppThemeM3 { EmailsDetailScreen( - items = items, onBackPressed = onBackPressedDispatcher::onBackPressed ) @@ -63,18 +64,11 @@ class EmailsDetailActivity : BaseAppCompatActivity() { } companion object { - fun start( - context: Context, - items: List - ) { + fun start(context: Context) { val intent = Intent( context, EmailsDetailActivity::class.java - ).apply { - putExtra( - EXTRA_ITEMS, ArrayList(items) - ) - } + ) context.startActivity(intent) } } @@ -83,9 +77,40 @@ class EmailsDetailActivity : BaseAppCompatActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EmailsDetailScreen( - items: List, + viewModel: EmailsDetailViewModel = viewModel(), onBackPressed: () -> Unit ) { + val items by viewModel.items.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val isLoadingMore by + viewModel.isLoadingMore.collectAsState() + val canLoadMore by + viewModel.canLoadMore.collectAsState() + + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + viewModel.loadInitialPage() + } + + val shouldLoadMore by remember { + derivedStateOf { + val lastVisible = + listState.layoutInfo.visibleItemsInfo + .lastOrNull()?.index ?: 0 + val totalItems = + listState.layoutInfo.totalItemsCount + canLoadMore && !isLoadingMore && + totalItems > 0 && + lastVisible >= totalItems - + LOAD_MORE_THRESHOLD + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) viewModel.loadMore() + } + val title = stringResource( R.string.stats_subscribers_emails ) @@ -95,7 +120,9 @@ private fun EmailsDetailScreen( TopAppBar( title = { Text(text = title) }, navigationIcon = { - IconButton(onClick = onBackPressed) { + IconButton( + onClick = onBackPressed + ) { Icon( Icons.AutoMirrored.Filled .ArrowBack, @@ -107,29 +134,66 @@ private fun EmailsDetailScreen( ) } ) { contentPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .padding(horizontal = 16.dp) - ) { - item { - Spacer(modifier = Modifier.height(8.dp)) - DetailEmailColumnHeaders() - Spacer(modifier = Modifier.height(8.dp)) + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - - itemsIndexed(items) { index, item -> - DetailEmailRow(item = item) - if (index < items.lastIndex) { + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp) + ) { + item { + Spacer( + modifier = Modifier.height(8.dp) + ) + DetailEmailColumnHeaders() Spacer( - modifier = Modifier.height(4.dp) + modifier = Modifier.height(8.dp) ) } - } - item { - Spacer(modifier = Modifier.height(16.dp)) + itemsIndexed(items) { index, item -> + DetailEmailRow(item = item) + if (index < items.lastIndex) { + Spacer( + modifier = + Modifier.height(4.dp) + ) + } + } + + if (isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = + Alignment.Center + ) { + CircularProgressIndicator( + modifier = + Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + } + + item { + Spacer( + modifier = Modifier.height(16.dp) + ) + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt new file mode 100644 index 000000000000..af87b9402a63 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt @@ -0,0 +1,117 @@ +package org.wordpress.android.ui.newstats.subscribers.emails + +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 kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.EmailsStatsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository +import javax.inject.Inject + +private const val PAGE_SIZE = 20 + +@HiltViewModel +class EmailsDetailViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository +) : ViewModel() { + private val _items = MutableStateFlow>( + emptyList() + ) + val items: StateFlow> = + _items.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = + _isLoading.asStateFlow() + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = + _isLoadingMore.asStateFlow() + + private val _canLoadMore = MutableStateFlow(true) + val canLoadMore: StateFlow = + _canLoadMore.asStateFlow() + + private var currentQuantity = 0 + private val paginationMutex = Mutex() + + fun loadInitialPage() { + viewModelScope.launch { + paginationMutex.withLock { + if (_items.value.isNotEmpty()) return@launch + currentQuantity = PAGE_SIZE + _isLoading.value = true + _canLoadMore.value = true + fetchEmails(currentQuantity, isInitial = true) + _isLoading.value = false + } + } + } + + fun loadMore() { + viewModelScope.launch { + paginationMutex.withLock { + if (!_canLoadMore.value || + _isLoadingMore.value + ) return@launch + _isLoadingMore.value = true + currentQuantity += PAGE_SIZE + fetchEmails( + currentQuantity, isInitial = false + ) + _isLoadingMore.value = false + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun fetchEmails( + quantity: Int, + isInitial: Boolean + ) { + val siteId = selectedSiteRepository + .getSelectedSite()?.siteId ?: return + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) return + statsRepository.init(accessToken) + + try { + val result = statsRepository.fetchEmailsSummary( + siteId = siteId, + quantity = quantity + ) + when (result) { + is EmailsStatsResult.Success -> { + val newItems = result.items.map { + EmailListItem( + title = it.title, + opens = it.opens, + clicks = it.clicks + ) + } + _items.value = newItems + _canLoadMore.value = + newItems.size == quantity + } + is EmailsStatsResult.Error -> { + if (isInitial) { + _canLoadMore.value = false + } else { + currentQuantity -= PAGE_SIZE + } + } + } + } catch (_: Exception) { + if (!isInitial) currentQuantity -= PAGE_SIZE + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt index 21fa93d8b66d..65f88c34c428 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt @@ -5,17 +5,21 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -24,34 +28,33 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel 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.util.extensions.getParcelableArrayListCompat -private const val EXTRA_ITEMS = "extra_items" +private const val LOAD_MORE_THRESHOLD = 5 @AndroidEntryPoint -class SubscribersListDetailActivity : BaseAppCompatActivity() { +class SubscribersListDetailActivity : + BaseAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - val items = intent.extras - ?.getParcelableArrayListCompat( - EXTRA_ITEMS - ) ?: arrayListOf() - setContent { AppThemeM3 { SubscribersListDetailScreen( - items = items, onBackPressed = onBackPressedDispatcher::onBackPressed ) @@ -60,18 +63,11 @@ class SubscribersListDetailActivity : BaseAppCompatActivity() { } companion object { - fun start( - context: Context, - items: List - ) { + fun start(context: Context) { val intent = Intent( context, SubscribersListDetailActivity::class.java - ).apply { - putExtra( - EXTRA_ITEMS, ArrayList(items) - ) - } + ) context.startActivity(intent) } } @@ -80,9 +76,41 @@ class SubscribersListDetailActivity : BaseAppCompatActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SubscribersListDetailScreen( - items: List, + viewModel: SubscribersListDetailViewModel = + viewModel(), onBackPressed: () -> Unit ) { + val items by viewModel.items.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val isLoadingMore by + viewModel.isLoadingMore.collectAsState() + val canLoadMore by + viewModel.canLoadMore.collectAsState() + + val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + viewModel.loadInitialPage() + } + + val shouldLoadMore by remember { + derivedStateOf { + val lastVisible = + listState.layoutInfo.visibleItemsInfo + .lastOrNull()?.index ?: 0 + val totalItems = + listState.layoutInfo.totalItemsCount + canLoadMore && !isLoadingMore && + totalItems > 0 && + lastVisible >= totalItems - + LOAD_MORE_THRESHOLD + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) viewModel.loadMore() + } + val title = stringResource( R.string.stats_subscribers_list ) @@ -92,7 +120,9 @@ private fun SubscribersListDetailScreen( TopAppBar( title = { Text(text = title) }, navigationIcon = { - IconButton(onClick = onBackPressed) { + IconButton( + onClick = onBackPressed + ) { Icon( Icons.AutoMirrored.Filled .ArrowBack, @@ -104,31 +134,68 @@ private fun SubscribersListDetailScreen( ) } ) { contentPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .padding(horizontal = 16.dp) - ) { - item { - Spacer(modifier = Modifier.height(8.dp)) - DetailColumnHeaders( - itemCount = items.size - ) - Spacer(modifier = Modifier.height(8.dp)) + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } - - itemsIndexed(items) { index, item -> - DetailSubscriberRow(item = item) - if (index < items.lastIndex) { + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp) + ) { + item { + Spacer( + modifier = Modifier.height(8.dp) + ) + DetailColumnHeaders( + itemCount = items.size + ) Spacer( - modifier = Modifier.height(4.dp) + modifier = Modifier.height(8.dp) ) } - } - item { - Spacer(modifier = Modifier.height(16.dp)) + itemsIndexed(items) { index, item -> + DetailSubscriberRow(item = item) + if (index < items.lastIndex) { + Spacer( + modifier = + Modifier.height(4.dp) + ) + } + } + + if (isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = + Alignment.Center + ) { + CircularProgressIndicator( + modifier = + Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + } + + item { + Spacer( + modifier = Modifier.height(16.dp) + ) + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt new file mode 100644 index 000000000000..01e75aa7c818 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt @@ -0,0 +1,120 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +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 kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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.SubscribersListResult +import javax.inject.Inject + +private const val PAGE_SIZE = 20 + +@HiltViewModel +class SubscribersListDetailViewModel @Inject constructor( + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val statsRepository: StatsRepository +) : ViewModel() { + private val _items = MutableStateFlow>( + emptyList() + ) + val items: StateFlow> = + _items.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = + _isLoading.asStateFlow() + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = + _isLoadingMore.asStateFlow() + + private val _canLoadMore = MutableStateFlow(true) + val canLoadMore: StateFlow = + _canLoadMore.asStateFlow() + + private var currentPage = 0 + private val paginationMutex = Mutex() + + fun loadInitialPage() { + viewModelScope.launch { + paginationMutex.withLock { + if (_items.value.isNotEmpty()) return@launch + currentPage = 1 + _isLoading.value = true + _canLoadMore.value = true + fetchPage(currentPage, isInitial = true) + _isLoading.value = false + } + } + } + + fun loadMore() { + viewModelScope.launch { + paginationMutex.withLock { + if (!_canLoadMore.value || + _isLoadingMore.value + ) return@launch + _isLoadingMore.value = true + currentPage++ + fetchPage(currentPage, isInitial = false) + _isLoadingMore.value = false + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun fetchPage( + page: Int, + isInitial: Boolean + ) { + val siteId = selectedSiteRepository + .getSelectedSite()?.siteId ?: return + val accessToken = accountStore.accessToken + if (accessToken.isNullOrEmpty()) return + statsRepository.init(accessToken) + + try { + val result = statsRepository.fetchSubscribersList( + siteId = siteId, + perPage = PAGE_SIZE, + page = page + ) + when (result) { + is SubscribersListResult.Success -> { + val newItems = result.subscribers.map { + SubscriberListItem( + displayName = it.displayName, + subscribedSince = + it.subscribedSince + ) + } + if (isInitial) { + _items.value = newItems + } else { + _items.value = _items.value + newItems + } + _canLoadMore.value = + newItems.size == PAGE_SIZE + } + is SubscribersListResult.Error -> { + if (isInitial) { + _canLoadMore.value = false + } else { + currentPage-- + } + } + } + } catch (_: Exception) { + if (!isInitial) currentPage-- + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt index 39f5f18f526b..77402f1ffd36 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt @@ -10,7 +10,6 @@ import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject private const val CARD_MAX_ITEMS = 5 -private const val DETAIL_MAX_ITEMS = 100 @HiltViewModel class SubscribersListViewModel @Inject constructor( @@ -32,24 +31,6 @@ class SubscribersListViewModel @Inject constructor( isAuthError: Boolean ) = SubscribersListUiState.Error(message, isAuthError) - suspend fun getDetailData(): List { - val siteId = getSiteId() ?: return emptyList() - val result = statsRepository.fetchSubscribersList( - siteId, DETAIL_MAX_ITEMS - ) - return when (result) { - is SubscribersListResult.Success -> - result.subscribers.map { - SubscriberListItem( - displayName = it.displayName, - subscribedSince = - it.subscribedSince - ) - } - is SubscribersListResult.Error -> emptyList() - } - } - override suspend fun loadDataInternal(siteId: Long) { when ( val result = statsRepository diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt index 8e04be0c23a0..cafb5660df15 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt @@ -7,6 +7,8 @@ import org.junit.Test import org.mockito.Mock import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +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 @@ -118,7 +120,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given success, when fetchSubscribersList, then items are mapped correctly`() = test { - whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + whenever(statsDataSource.fetchSubscribersByUserType(any(), any(), any())) .thenReturn( SubscribersByUserTypeDataResult.Success( listOf( @@ -144,10 +146,23 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { assertThat(success.subscribers[1].displayName).isEqualTo("Jane") } + @Test + fun `given page param, when fetchSubscribersList, then page is forwarded`() = + test { + whenever(statsDataSource.fetchSubscribersByUserType(any(), any(), any())) + .thenReturn(SubscribersByUserTypeDataResult.Success(emptyList())) + + repository.fetchSubscribersList(TEST_SITE_ID, perPage = 20, page = 3) + + verify(statsDataSource).fetchSubscribersByUserType( + eq(TEST_SITE_ID), eq(20), eq(3) + ) + } + @Test fun `given empty response, when fetchSubscribersList, then empty list is returned`() = test { - whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + whenever(statsDataSource.fetchSubscribersByUserType(any(), any(), any())) .thenReturn(SubscribersByUserTypeDataResult.Success(emptyList())) val result = repository.fetchSubscribersList(TEST_SITE_ID) @@ -159,7 +174,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given error, when fetchSubscribersList, then error result is returned`() = test { - whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + whenever(statsDataSource.fetchSubscribersByUserType(any(), any(), any())) .thenReturn( SubscribersByUserTypeDataResult.Error(StatsErrorType.API_ERROR) ) @@ -174,7 +189,7 @@ class StatsRepositorySubscribersTest : BaseUnitTest() { @Test fun `given auth error, when fetchSubscribersList, then isAuthError is true`() = test { - whenever(statsDataSource.fetchSubscribersByUserType(any(), any())) + whenever(statsDataSource.fetchSubscribersByUserType(any(), any(), any())) .thenReturn( SubscribersByUserTypeDataResult.Error(StatsErrorType.AUTH_ERROR) ) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt index c96c33861f05..5e3abec9cf28 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt @@ -236,24 +236,6 @@ class EmailsCardViewModelTest : BaseUnitTest() { assertThat(viewModel.isRefreshing.value).isFalse() } - @Test - fun `when getDetailData is called, then all items are returned not truncated`() = test { - val items = (1..10).map { - EmailItemData( - title = "Email $it", - opens = it.toLong() * 100, - clicks = it.toLong() * 10 - ) - } - whenever(statsRepository.fetchEmailsSummary(any(), any())) - .thenReturn(EmailsStatsResult.Success(items)) - - initViewModel() - advanceUntilIdle() - - assertThat(viewModel.getDetailData()).hasSize(10) - } - @Test fun `when data loads, then items map title opens and clicks correctly`() = test { val items = listOf( diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt new file mode 100644 index 000000000000..be6bb17af6ff --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt @@ -0,0 +1,310 @@ +package org.wordpress.android.ui.newstats.subscribers.emails + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.EmailItemData +import org.wordpress.android.ui.newstats.repository.EmailsStatsResult +import org.wordpress.android.ui.newstats.repository.StatsRepository + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) +class EmailsDetailViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + private lateinit var viewModel: EmailsDetailViewModel + + 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) + viewModel = EmailsDetailViewModel( + selectedSiteRepository, + accountStore, + statsRepository + ) + } + + @Test + fun `when loadInitialPage succeeds, then items are populated`() = + test { + whenever( + statsRepository.fetchEmailsSummary(any(), any()) + ).thenReturn( + EmailsStatsResult.Success(createItems(3)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).hasSize(3) + assertThat(viewModel.isLoading.value).isFalse() + } + + @Test + fun `when loadInitialPage returns full page, then canLoadMore is true`() = + test { + whenever( + statsRepository.fetchEmailsSummary(any(), any()) + ).thenReturn( + EmailsStatsResult.Success( + createItems(PAGE_SIZE) + ) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.canLoadMore.value).isTrue() + } + + @Test + fun `when loadInitialPage returns fewer than page size, then canLoadMore is false`() = + test { + whenever( + statsRepository.fetchEmailsSummary(any(), any()) + ).thenReturn( + EmailsStatsResult.Success(createItems(5)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.canLoadMore.value).isFalse() + } + + @Test + fun `when loadMore succeeds, then items are replaced with full set`() = + test { + whenever( + statsRepository.fetchEmailsSummary( + any(), eq(PAGE_SIZE) + ) + ).thenReturn( + EmailsStatsResult.Success( + createItems(PAGE_SIZE) + ) + ) + whenever( + statsRepository.fetchEmailsSummary( + any(), eq(PAGE_SIZE * 2) + ) + ).thenReturn( + EmailsStatsResult.Success( + createItems(PAGE_SIZE * 2) + ) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + assertThat(viewModel.items.value) + .hasSize(PAGE_SIZE * 2) + assertThat(viewModel.isLoadingMore.value).isFalse() + } + + @Test + fun `when loadMore returns fewer than quantity, then canLoadMore becomes false`() = + test { + whenever( + statsRepository.fetchEmailsSummary( + any(), eq(PAGE_SIZE) + ) + ).thenReturn( + EmailsStatsResult.Success( + createItems(PAGE_SIZE) + ) + ) + whenever( + statsRepository.fetchEmailsSummary( + any(), eq(PAGE_SIZE * 2) + ) + ).thenReturn( + EmailsStatsResult.Success( + createItems(PAGE_SIZE + 5) + ) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + assertThat(viewModel.canLoadMore.value).isFalse() + } + + @Test + fun `when loadInitialPage errors, then canLoadMore is false`() = + test { + whenever( + statsRepository.fetchEmailsSummary(any(), any()) + ).thenReturn( + EmailsStatsResult.Error(messageResId = 0) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + assertThat(viewModel.canLoadMore.value).isFalse() + } + + @Test + fun `when loadMore errors, then quantity is reverted for retry`() = + test { + whenever( + statsRepository.fetchEmailsSummary( + any(), eq(PAGE_SIZE) + ) + ).thenReturn( + EmailsStatsResult.Success( + createItems(PAGE_SIZE) + ) + ) + whenever( + statsRepository.fetchEmailsSummary( + any(), eq(PAGE_SIZE * 2) + ) + ).thenReturn( + EmailsStatsResult.Error(messageResId = 0) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + // Items unchanged from first load + assertThat(viewModel.items.value) + .hasSize(PAGE_SIZE) + // canLoadMore still true so retry is possible + assertThat(viewModel.canLoadMore.value).isTrue() + } + + @Test + fun `when loadInitialPage called twice, then only loads once`() = + test { + whenever( + statsRepository.fetchEmailsSummary(any(), any()) + ).thenReturn( + EmailsStatsResult.Success(createItems(3)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadInitialPage() + advanceUntilIdle() + + verify(statsRepository, times(1)) + .fetchEmailsSummary(any(), any()) + } + + @Test + fun `when no site selected, then items remain empty`() = + test { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(null) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + } + + @Test + fun `when access token is empty, then items remain empty`() = + test { + whenever(accountStore.accessToken).thenReturn("") + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + } + + @Test + fun `when items map correctly, then title opens and clicks are set`() = + test { + val items = listOf( + EmailItemData( + title = "My Newsletter", + opens = 500L, + clicks = 42L + ) + ) + whenever( + statsRepository.fetchEmailsSummary(any(), any()) + ).thenReturn(EmailsStatsResult.Success(items)) + + viewModel.loadInitialPage() + advanceUntilIdle() + + val item = viewModel.items.value[0] + assertThat(item.title) + .isEqualTo("My Newsletter") + assertThat(item.opens).isEqualTo(500L) + assertThat(item.clicks).isEqualTo(42L) + } + + @Test + fun `when exception thrown, then items remain unchanged`() = + test { + whenever( + statsRepository.fetchEmailsSummary(any(), any()) + ).thenThrow(RuntimeException("Test exception")) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + } + + private fun createItems(count: Int) = + (1..count).map { + EmailItemData( + title = "Email $it", + opens = it.toLong() * 100, + clicks = it.toLong() * 10 + ) + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val PAGE_SIZE = 20 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt new file mode 100644 index 000000000000..9ea977aae5bd --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt @@ -0,0 +1,315 @@ +package org.wordpress.android.ui.newstats.subscribers.subscriberslist + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.ui.newstats.repository.SubscriberItemData +import org.wordpress.android.ui.newstats.repository.SubscribersListResult + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) +class SubscribersListDetailViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + private lateinit var viewModel: SubscribersListDetailViewModel + + 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) + viewModel = SubscribersListDetailViewModel( + selectedSiteRepository, + accountStore, + statsRepository + ) + } + + @Test + fun `when loadInitialPage succeeds, then items are populated`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), any() + ) + ).thenReturn( + SubscribersListResult.Success(createItems(3)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).hasSize(3) + assertThat(viewModel.isLoading.value).isFalse() + } + + @Test + fun `when loadInitialPage succeeds with full page, then canLoadMore is true`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), any() + ) + ).thenReturn( + SubscribersListResult.Success(createItems(PAGE_SIZE)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.canLoadMore.value).isTrue() + } + + @Test + fun `when loadInitialPage returns fewer than page size, then canLoadMore is false`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), any() + ) + ).thenReturn( + SubscribersListResult.Success(createItems(5)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.canLoadMore.value).isFalse() + } + + @Test + fun `when loadMore succeeds, then items are appended`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), eq(1) + ) + ).thenReturn( + SubscribersListResult.Success(createItems(PAGE_SIZE)) + ) + whenever( + statsRepository.fetchSubscribersList( + any(), any(), eq(2) + ) + ).thenReturn( + SubscribersListResult.Success( + createItems(5, startIndex = PAGE_SIZE) + ) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + assertThat(viewModel.items.value) + .hasSize(PAGE_SIZE + 5) + assertThat(viewModel.isLoadingMore.value).isFalse() + } + + @Test + fun `when loadMore returns fewer than page size, then canLoadMore becomes false`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), eq(1) + ) + ).thenReturn( + SubscribersListResult.Success(createItems(PAGE_SIZE)) + ) + whenever( + statsRepository.fetchSubscribersList( + any(), any(), eq(2) + ) + ).thenReturn( + SubscribersListResult.Success(createItems(3)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + assertThat(viewModel.canLoadMore.value).isFalse() + } + + @Test + fun `when loadInitialPage errors, then canLoadMore is false`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), any() + ) + ).thenReturn( + SubscribersListResult.Error( + messageResId = 0 + ) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + assertThat(viewModel.canLoadMore.value).isFalse() + } + + @Test + fun `when loadMore errors, then page is decremented for retry`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), eq(1) + ) + ).thenReturn( + SubscribersListResult.Success(createItems(PAGE_SIZE)) + ) + whenever( + statsRepository.fetchSubscribersList( + any(), any(), eq(2) + ) + ).thenReturn( + SubscribersListResult.Error(messageResId = 0) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + // Items unchanged from first page + assertThat(viewModel.items.value).hasSize(PAGE_SIZE) + // canLoadMore still true so retry is possible + assertThat(viewModel.canLoadMore.value).isTrue() + } + + @Test + fun `when loadInitialPage called twice, then only loads once`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), any() + ) + ).thenReturn( + SubscribersListResult.Success(createItems(3)) + ) + + viewModel.loadInitialPage() + advanceUntilIdle() + + viewModel.loadInitialPage() + advanceUntilIdle() + + verify(statsRepository, times(1)) + .fetchSubscribersList(any(), any(), any()) + } + + @Test + fun `when no site selected, then items remain empty`() = + test { + whenever(selectedSiteRepository.getSelectedSite()) + .thenReturn(null) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + } + + @Test + fun `when access token is empty, then items remain empty`() = + test { + whenever(accountStore.accessToken).thenReturn("") + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + } + + @Test + fun `when items map correctly, then displayName and subscribedSince are set`() = + test { + val items = listOf( + SubscriberItemData( + displayName = "John Doe", + subscribedSince = "2024-06-15T10:00:00" + ) + ) + whenever( + statsRepository.fetchSubscribersList( + any(), any(), any() + ) + ).thenReturn(SubscribersListResult.Success(items)) + + viewModel.loadInitialPage() + advanceUntilIdle() + + val item = viewModel.items.value[0] + assertThat(item.displayName) + .isEqualTo("John Doe") + assertThat(item.subscribedSince) + .isEqualTo("2024-06-15T10:00:00") + } + + @Test + fun `when exception thrown, then items remain unchanged`() = + test { + whenever( + statsRepository.fetchSubscribersList( + any(), any(), any() + ) + ).thenThrow(RuntimeException("Test exception")) + + viewModel.loadInitialPage() + advanceUntilIdle() + + assertThat(viewModel.items.value).isEmpty() + } + + private fun createItems( + count: Int, + startIndex: Int = 0 + ) = (startIndex until startIndex + count).map { + SubscriberItemData( + displayName = "User $it", + subscribedSince = "2024-01-01" + ) + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = "test_access_token" + private const val PAGE_SIZE = 20 + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt index 1b9baef26d36..062b92076302 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt @@ -111,7 +111,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { subscribedSince = "2024-01-0$it" ) } - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn(SubscribersListResult.Success(items)) initViewModel() @@ -130,7 +130,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { subscribedSince = "2024-01-0$it" ) } - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn(SubscribersListResult.Success(items)) initViewModel() @@ -142,7 +142,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { @Test fun `when data loads with empty list, then loaded state has empty items`() = test { - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn(SubscribersListResult.Success(emptyList())) initViewModel() @@ -154,7 +154,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { @Test fun `when fetch fails, then error state is emitted`() = test { - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn( SubscribersListResult.Error( messageResId = R.string.stats_error_api @@ -172,7 +172,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { @Test fun `when exception is thrown, then error state has exception message`() = test { - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenThrow(RuntimeException("Test exception")) initViewModel() @@ -186,7 +186,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { @Test fun `when loadDataIfNeeded called multiple times, then data is only loaded once`() = test { - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn(SubscribersListResult.Success(createTestItems())) viewModel = SubscribersListViewModel( @@ -201,12 +201,12 @@ class SubscribersListViewModelTest : BaseUnitTest() { viewModel.loadDataIfNeeded() advanceUntilIdle() - verify(statsRepository, times(1)).fetchSubscribersList(eq(TEST_SITE_ID), any()) + verify(statsRepository, times(1)).fetchSubscribersList(eq(TEST_SITE_ID), any(), any()) } @Test fun `when loadData is called again, then repository is called again`() = test { - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn(SubscribersListResult.Success(createTestItems())) initViewModel() @@ -215,12 +215,12 @@ class SubscribersListViewModelTest : BaseUnitTest() { viewModel.loadData() advanceUntilIdle() - verify(statsRepository, times(2)).fetchSubscribersList(eq(TEST_SITE_ID), any()) + verify(statsRepository, times(2)).fetchSubscribersList(eq(TEST_SITE_ID), any(), any()) } @Test fun `when refresh is called, then isRefreshing becomes false after completion`() = test { - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn(SubscribersListResult.Success(createTestItems())) initViewModel() @@ -234,23 +234,6 @@ class SubscribersListViewModelTest : BaseUnitTest() { assertThat(viewModel.isRefreshing.value).isFalse() } - @Test - fun `when getDetailData is called, then all items are returned not truncated`() = test { - val items = (1..10).map { - SubscriberItemData( - displayName = "User $it", - subscribedSince = "2024-01-0$it" - ) - } - whenever(statsRepository.fetchSubscribersList(any(), any())) - .thenReturn(SubscribersListResult.Success(items)) - - initViewModel() - advanceUntilIdle() - - assertThat(viewModel.getDetailData()).hasSize(10) - } - @Test fun `when data loads, then items map displayName and subscribedSince correctly`() = test { val items = listOf( @@ -259,7 +242,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { subscribedSince = "2024-06-15T10:00:00" ) ) - whenever(statsRepository.fetchSubscribersList(any(), any())) + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenReturn(SubscribersListResult.Success(items)) initViewModel() From 05e0989b66365387e1df7d84e7b211dae7462da3 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 27 Feb 2026 15:05:45 +0100 Subject: [PATCH 14/21] Fix bugs, thread safety issues, and performance in Subscribers tab - Bug #1: Add error state with retry to detail Activities (blank screen on error) - Bug #2: Cancel previous load job on tab switch to prevent race conditions - Bug #3: Reset isLoadedSuccessfully in refresh() to allow data reload - Bug #4: Add duplicate guard in addCard() to prevent duplicate cards - Bug #9: Use resource string instead of raw e.message for error display - Thread safety #7: Use AtomicBoolean for isInitialLoad in SubscribersTabViewModel - Performance #11: Pre-compute formatted dates in ViewModel, memoize in card composable - Update all tests to match new error handling and API signatures Co-Authored-By: Claude Opus 4.6 --- ...SubscribersCardsConfigurationRepository.kt | 1 + .../BaseSubscribersCardViewModel.kt | 21 +++++-- .../subscribers/SubscribersTabViewModel.kt | 6 +- .../emails/EmailsDetailActivity.kt | 44 +++++++++++++++ .../emails/EmailsDetailViewModel.kt | 13 ++++- .../subscriberslist/SubscribersListCard.kt | 14 +++-- .../SubscribersListDetailActivity.kt | 56 +++++++++++++++++-- .../SubscribersListDetailViewModel.kt | 43 +++++++++++--- .../subscriberslist/SubscribersListUiState.kt | 3 +- .../AllTimeSubscribersViewModelTest.kt | 7 ++- .../emails/EmailsCardViewModelTest.kt | 5 +- .../emails/EmailsDetailViewModelTest.kt | 4 +- .../SubscribersGraphViewModelTest.kt | 3 +- .../SubscribersListDetailViewModelTest.kt | 40 +++++++------ .../SubscribersListViewModelTest.kt | 5 +- 15 files changed, 212 insertions(+), 53 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt index 5744ce11a834..67faf5a077c0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt @@ -72,6 +72,7 @@ class SubscribersCardsConfigurationRepository @Inject constructor( cardType: SubscribersCardType ): Unit = withContext(ioDispatcher) { val current = getConfiguration(siteId) + if (cardType in current.visibleCards) return@withContext val newVisibleCards = current.visibleCards + cardType saveConfiguration( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt index 43314495d8f2..dca15d8f9aaf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt @@ -2,6 +2,7 @@ package org.wordpress.android.ui.newstats.subscribers import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -10,6 +11,7 @@ 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.repository.StatsRepository +import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider import java.util.concurrent.atomic.AtomicBoolean @@ -25,17 +27,21 @@ abstract class BaseSubscribersCardViewModel( val uiState: StateFlow = _uiState.asStateFlow() private val _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + val isRefreshing: StateFlow = + _isRefreshing.asStateFlow() private val isLoading = AtomicBoolean(false) private val isLoadedSuccessfully = AtomicBoolean(false) + private var loadJob: Job? = null protected abstract val loadingState: UiState protected abstract fun errorState( message: String, isAuthError: Boolean = false ): UiState - protected abstract suspend fun loadDataInternal(siteId: Long) + protected abstract suspend fun loadDataInternal( + siteId: Long + ) fun loadDataIfNeeded() { if (isLoadedSuccessfully.get() || @@ -50,6 +56,7 @@ abstract class BaseSubscribersCardViewModel( val accessToken = accountStore.accessToken if (accessToken.isNullOrEmpty()) return statsRepository.init(accessToken) + resetLoadedSuccessfully() viewModelScope.launch { try { _isRefreshing.value = true @@ -90,7 +97,8 @@ abstract class BaseSubscribersCardViewModel( statsRepository.init(accessToken) updateState(loadingState) - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { try { fetchData(site.siteId) } finally { @@ -103,9 +111,14 @@ abstract class BaseSubscribersCardViewModel( try { loadDataInternal(siteId) } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Error loading stats data", + e + ) updateState( errorState( - e.message ?: resourceProvider.getString( + resourceProvider.getString( R.string.stats_error_unknown ) ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt index 02e7a0f93f42..1ce664d6cadc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.ui.mysite.SelectedSiteRepository +import java.util.concurrent.atomic.AtomicBoolean import org.wordpress.android.ui.newstats.repository.SubscribersCardsConfigurationRepository import org.wordpress.android.util.NetworkUtilsWrapper import javax.inject.Inject @@ -41,7 +42,7 @@ class SubscribersTabViewModel @Inject constructor( val cardsToLoad: StateFlow> = _cardsToLoad.asStateFlow() - private var isInitialLoad = true + private val isInitialLoad = AtomicBoolean(true) private val siteId: Long get() = selectedSiteRepository @@ -91,9 +92,8 @@ class SubscribersTabViewModel @Inject constructor( ) { _visibleCards.value = config.visibleCards _hiddenCards.value = config.hiddenCards() - if (isInitialLoad) { + if (isInitialLoad.compareAndSet(true, false)) { _cardsToLoad.value = config.visibleCards - isInitialLoad = false } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt index 6cc16743f4a6..12ed293cc42e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent 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.fillMaxSize @@ -18,6 +19,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -86,6 +88,7 @@ private fun EmailsDetailScreen( viewModel.isLoadingMore.collectAsState() val canLoadMore by viewModel.canLoadMore.collectAsState() + val hasError by viewModel.hasError.collectAsState() val listState = rememberLazyListState() @@ -143,6 +146,13 @@ private fun EmailsDetailScreen( ) { CircularProgressIndicator() } + } else if (hasError) { + ErrorContent( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + onRetry = { viewModel.loadInitialPage() } + ) } else { LazyColumn( state = listState, @@ -295,3 +305,37 @@ private fun DetailEmailRow(item: EmailListItem) { ) } } + +@Composable +private fun ErrorContent( + modifier: Modifier = Modifier, + onRetry: () -> Unit +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = + Alignment.CenterHorizontally + ) { + Text( + text = stringResource( + R.string.stats_error_api + ), + style = MaterialTheme + .typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text( + text = stringResource( + R.string.retry + ) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt index af87b9402a63..271364c0162d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt @@ -41,6 +41,10 @@ class EmailsDetailViewModel @Inject constructor( val canLoadMore: StateFlow = _canLoadMore.asStateFlow() + private val _hasError = MutableStateFlow(false) + val hasError: StateFlow = + _hasError.asStateFlow() + private var currentQuantity = 0 private val paginationMutex = Mutex() @@ -50,6 +54,7 @@ class EmailsDetailViewModel @Inject constructor( if (_items.value.isNotEmpty()) return@launch currentQuantity = PAGE_SIZE _isLoading.value = true + _hasError.value = false _canLoadMore.value = true fetchEmails(currentQuantity, isInitial = true) _isLoading.value = false @@ -104,6 +109,7 @@ class EmailsDetailViewModel @Inject constructor( } is EmailsStatsResult.Error -> { if (isInitial) { + _hasError.value = true _canLoadMore.value = false } else { currentQuantity -= PAGE_SIZE @@ -111,7 +117,12 @@ class EmailsDetailViewModel @Inject constructor( } } } catch (_: Exception) { - if (!isInitial) currentQuantity -= PAGE_SIZE + if (isInitial) { + _hasError.value = true + _canLoadMore.value = false + } else { + currentQuantity -= PAGE_SIZE + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt index ab443438daca..c61b9fec458f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier @@ -229,11 +230,16 @@ private fun SubscriberItemRow( modifier = Modifier.weight(1f) ) Spacer(modifier = Modifier.width(12.dp)) + val resources = LocalContext.current.resources + val formattedDate = remember( + item.subscribedSince + ) { + formatSubscriberDate( + item.subscribedSince, resources + ) + } Text( - text = formatSubscriberDate( - item.subscribedSince, - LocalContext.current.resources - ), + text = formattedDate, style = MaterialTheme .typography.bodySmall, color = MaterialTheme diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt index 65f88c34c428..3dca0c52e122 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +20,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -86,11 +88,13 @@ private fun SubscribersListDetailScreen( viewModel.isLoadingMore.collectAsState() val canLoadMore by viewModel.canLoadMore.collectAsState() + val hasError by viewModel.hasError.collectAsState() + val resources = LocalContext.current.resources val listState = rememberLazyListState() LaunchedEffect(Unit) { - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) } val shouldLoadMore by remember { @@ -108,7 +112,7 @@ private fun SubscribersListDetailScreen( } LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore) viewModel.loadMore() + if (shouldLoadMore) viewModel.loadMore(resources) } val title = stringResource( @@ -143,6 +147,15 @@ private fun SubscribersListDetailScreen( ) { CircularProgressIndicator() } + } else if (hasError) { + ErrorContent( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + onRetry = { + viewModel.loadInitialPage(resources) + } + ) } else { LazyColumn( state = listState, @@ -252,10 +265,7 @@ private fun DetailSubscriberRow( ) Spacer(modifier = Modifier.width(12.dp)) Text( - text = formatSubscriberDate( - item.subscribedSince, - LocalContext.current.resources - ), + text = item.formattedDate, style = MaterialTheme .typography.bodySmall, color = MaterialTheme @@ -263,3 +273,37 @@ private fun DetailSubscriberRow( ) } } + +@Composable +private fun ErrorContent( + modifier: Modifier = Modifier, + onRetry: () -> Unit +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = + Alignment.CenterHorizontally + ) { + Text( + text = stringResource( + R.string.stats_error_api + ), + style = MaterialTheme + .typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text( + text = stringResource( + R.string.retry + ) + ) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt index 01e75aa7c818..e21cca622f35 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.subscribers.subscriberslist +import android.content.res.Resources import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -41,23 +42,32 @@ class SubscribersListDetailViewModel @Inject constructor( val canLoadMore: StateFlow = _canLoadMore.asStateFlow() + private val _hasError = MutableStateFlow(false) + val hasError: StateFlow = + _hasError.asStateFlow() + private var currentPage = 0 private val paginationMutex = Mutex() - fun loadInitialPage() { + fun loadInitialPage(resources: Resources) { viewModelScope.launch { paginationMutex.withLock { if (_items.value.isNotEmpty()) return@launch currentPage = 1 _isLoading.value = true + _hasError.value = false _canLoadMore.value = true - fetchPage(currentPage, isInitial = true) + fetchPage( + currentPage, + isInitial = true, + resources = resources + ) _isLoading.value = false } } } - fun loadMore() { + fun loadMore(resources: Resources) { viewModelScope.launch { paginationMutex.withLock { if (!_canLoadMore.value || @@ -65,7 +75,11 @@ class SubscribersListDetailViewModel @Inject constructor( ) return@launch _isLoadingMore.value = true currentPage++ - fetchPage(currentPage, isInitial = false) + fetchPage( + currentPage, + isInitial = false, + resources = resources + ) _isLoadingMore.value = false } } @@ -74,7 +88,8 @@ class SubscribersListDetailViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") private suspend fun fetchPage( page: Int, - isInitial: Boolean + isInitial: Boolean, + resources: Resources ) { val siteId = selectedSiteRepository .getSelectedSite()?.siteId ?: return @@ -94,19 +109,26 @@ class SubscribersListDetailViewModel @Inject constructor( SubscriberListItem( displayName = it.displayName, subscribedSince = - it.subscribedSince + it.subscribedSince, + formattedDate = + formatSubscriberDate( + it.subscribedSince, + resources + ) ) } if (isInitial) { _items.value = newItems } else { - _items.value = _items.value + newItems + _items.value = + _items.value + newItems } _canLoadMore.value = newItems.size == PAGE_SIZE } is SubscribersListResult.Error -> { if (isInitial) { + _hasError.value = true _canLoadMore.value = false } else { currentPage-- @@ -114,7 +136,12 @@ class SubscribersListDetailViewModel @Inject constructor( } } } catch (_: Exception) { - if (!isInitial) currentPage-- + if (isInitial) { + _hasError.value = true + _canLoadMore.value = false + } else { + currentPage-- + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt index 59124a846b7d..958a809f8c4b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt @@ -25,5 +25,6 @@ sealed class SubscribersListUiState { @Parcelize data class SubscriberListItem( val displayName: String, - val subscribedSince: String + val subscribedSince: String, + val formattedDate: String = "" ) : Parcelable diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt index 96554409f6a4..8de5ec77d93b 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt @@ -142,7 +142,7 @@ class AllTimeSubscribersViewModelTest : BaseUnitTest() { } @Test - fun `when exception is thrown, then error state has exception message`() = test { + fun `when exception is thrown, then error state has unknown error message`() = test { whenever(statsRepository.fetchSubscribersAllTime(any())) .thenThrow(RuntimeException("Test exception")) @@ -152,7 +152,7 @@ class AllTimeSubscribersViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) assertThat((state as AllTimeSubscribersUiState.Error).message) - .isEqualTo("Test exception") + .isEqualTo(UNKNOWN_ERROR) } @Test @@ -166,7 +166,7 @@ class AllTimeSubscribersViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(AllTimeSubscribersUiState.Error::class.java) assertThat((state as AllTimeSubscribersUiState.Error).message) - .isEqualTo("Unknown error") + .isEqualTo(UNKNOWN_ERROR) } @Test @@ -273,5 +273,6 @@ class AllTimeSubscribersViewModelTest : BaseUnitTest() { private const val TEST_60D = 900L private const val TEST_90D = 850L private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = "Unknown error" } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt index 5e3abec9cf28..ec8019600e84 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt @@ -173,7 +173,7 @@ class EmailsCardViewModelTest : BaseUnitTest() { } @Test - fun `when exception is thrown, then error state has exception message`() = test { + fun `when exception is thrown, then error state has unknown error message`() = test { whenever(statsRepository.fetchEmailsSummary(any(), any())) .thenThrow(RuntimeException("Test exception")) @@ -183,7 +183,7 @@ class EmailsCardViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(EmailsCardUiState.Error::class.java) assertThat((state as EmailsCardUiState.Error).message) - .isEqualTo("Test exception") + .isEqualTo(UNKNOWN_ERROR) } @Test @@ -266,5 +266,6 @@ class EmailsCardViewModelTest : BaseUnitTest() { private const val TEST_SITE_ID = 123L private const val TEST_ACCESS_TOKEN = "test_access_token" private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = "Unknown error" } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt index be6bb17af6ff..e99ed04fe7ae 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt @@ -179,6 +179,7 @@ class EmailsDetailViewModelTest : BaseUnitTest() { assertThat(viewModel.items.value).isEmpty() assertThat(viewModel.canLoadMore.value).isFalse() + assertThat(viewModel.hasError.value).isTrue() } @Test @@ -281,7 +282,7 @@ class EmailsDetailViewModelTest : BaseUnitTest() { } @Test - fun `when exception thrown, then items remain unchanged`() = + fun `when exception thrown, then items remain empty and hasError is true`() = test { whenever( statsRepository.fetchEmailsSummary(any(), any()) @@ -291,6 +292,7 @@ class EmailsDetailViewModelTest : BaseUnitTest() { advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() + assertThat(viewModel.hasError.value).isTrue() } private fun createItems(count: Int) = diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt index 5cc77abb9b3c..e8bb4fd3580a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt @@ -183,7 +183,7 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { assertThat( (state as SubscribersGraphUiState.Error) .message - ).isEqualTo("Test error") + ).isEqualTo(UNKNOWN_ERROR) } @Test @@ -552,6 +552,7 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { "test_access_token" private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = "Unknown error" private const val TEST_COUNT_1 = 100L private const val TEST_COUNT_2 = 150L private const val TEST_COUNT_3 = 200L diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt index 9ea977aae5bd..627524bc0eef 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.subscribers.subscriberslist +import android.content.res.Resources import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -32,6 +33,9 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { @Mock private lateinit var statsRepository: StatsRepository + @Mock + private lateinit var resources: Resources + private lateinit var viewModel: SubscribersListDetailViewModel private val testSite = SiteModel().apply { @@ -64,7 +68,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(3)) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() assertThat(viewModel.items.value).hasSize(3) @@ -82,7 +86,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(PAGE_SIZE)) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() assertThat(viewModel.canLoadMore.value).isTrue() @@ -99,7 +103,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(5)) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() assertThat(viewModel.canLoadMore.value).isFalse() @@ -125,10 +129,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() - viewModel.loadMore() + viewModel.loadMore(resources) advanceUntilIdle() assertThat(viewModel.items.value) @@ -154,10 +158,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(3)) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() - viewModel.loadMore() + viewModel.loadMore(resources) advanceUntilIdle() assertThat(viewModel.canLoadMore.value).isFalse() @@ -176,11 +180,12 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() assertThat(viewModel.canLoadMore.value).isFalse() + assertThat(viewModel.hasError.value).isTrue() } @Test @@ -201,10 +206,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Error(messageResId = 0) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() - viewModel.loadMore() + viewModel.loadMore(resources) advanceUntilIdle() // Items unchanged from first page @@ -224,10 +229,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(3)) ) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() verify(statsRepository, times(1)) @@ -240,7 +245,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { whenever(selectedSiteRepository.getSelectedSite()) .thenReturn(null) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() @@ -251,7 +256,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { test { whenever(accountStore.accessToken).thenReturn("") - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() @@ -272,7 +277,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ).thenReturn(SubscribersListResult.Success(items)) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() val item = viewModel.items.value[0] @@ -283,7 +288,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { } @Test - fun `when exception thrown, then items remain unchanged`() = + fun `when exception thrown, then items remain empty and hasError is true`() = test { whenever( statsRepository.fetchSubscribersList( @@ -291,10 +296,11 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ).thenThrow(RuntimeException("Test exception")) - viewModel.loadInitialPage() + viewModel.loadInitialPage(resources) advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() + assertThat(viewModel.hasError.value).isTrue() } private fun createItems( diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt index 062b92076302..53442107c3df 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt @@ -171,7 +171,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { } @Test - fun `when exception is thrown, then error state has exception message`() = test { + fun `when exception is thrown, then error state has unknown error message`() = test { whenever(statsRepository.fetchSubscribersList(any(), any(), any())) .thenThrow(RuntimeException("Test exception")) @@ -181,7 +181,7 @@ class SubscribersListViewModelTest : BaseUnitTest() { val state = viewModel.uiState.value assertThat(state).isInstanceOf(SubscribersListUiState.Error::class.java) assertThat((state as SubscribersListUiState.Error).message) - .isEqualTo("Test exception") + .isEqualTo(UNKNOWN_ERROR) } @Test @@ -262,5 +262,6 @@ class SubscribersListViewModelTest : BaseUnitTest() { private const val TEST_SITE_ID = 123L private const val TEST_ACCESS_TOKEN = "test_access_token" private const val FAILED_TO_LOAD_ERROR = "Failed to load stats" + private const val UNKNOWN_ERROR = "Unknown error" } } From 09203d9e8cd90f233c28f8291862f66c456c097a Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 3 Mar 2026 12:37:19 +0100 Subject: [PATCH 15/21] Address code review: fix critical bugs, races, and warnings - Replace Resources with ContextProvider in SubscribersListDetailViewModel to avoid passing Activity resources into ViewModel (MVVM violation) - Fix refresh() race condition by canceling loadJob in BaseSubscribersCardViewModel - Add Mutex synchronization to SubscribersCardsConfigurationRepository - Fix isValidConfiguration no-op (filterIsInstance on typed list) - Expose PAGE_SIZE constants for test use instead of duplicating - Replace SimpleDateFormat with DateTimeFormatter in StatsDataSourceImpl - Log exceptions in fetchSubscribersGraph instead of swallowing silently - Use Period.between() for leap-year-accurate date formatting - Remove duplicate selectedTab from SubscribersGraphUiState.Loaded - Auto-load newly added cards in SubscribersTabContent - Add statsRepository.init() verification tests to all card ViewModels Co-Authored-By: Claude Opus 4.6 --- .../datasource/StatsDataSourceImpl.kt | 14 +- .../ui/newstats/repository/StatsRepository.kt | 5 + ...SubscribersCardsConfigurationRepository.kt | 132 +++++++++++------- .../BaseSubscribersCardViewModel.kt | 3 +- .../subscribers/SubscribersTabContent.kt | 21 +++ .../emails/EmailsDetailViewModel.kt | 10 +- .../SubscribersGraphUiState.kt | 3 +- .../SubscribersGraphViewModel.kt | 3 +- .../subscriberslist/SubscribersListCard.kt | 33 +++-- .../SubscribersListDetailActivity.kt | 8 +- .../SubscribersListDetailViewModel.kt | 33 ++--- .../AllTimeSubscribersViewModelTest.kt | 11 ++ .../emails/EmailsCardViewModelTest.kt | 11 ++ .../emails/EmailsDetailViewModelTest.kt | 3 +- .../SubscribersGraphViewModelTest.kt | 18 ++- .../FormatSubscriberDateTest.kt | 44 +++++- .../SubscribersListDetailViewModelTest.kt | 49 ++++--- .../SubscribersListViewModelTest.kt | 11 ++ 18 files changed, 282 insertions(+), 130 deletions(-) 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 266f3763b2a3..7e673b9becbe 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 @@ -1091,10 +1091,16 @@ class StatsDataSourceImpl @Inject constructor( subscribedSince = subscriber.dateSubscribed ?.let { - java.text.SimpleDateFormat( - "yyyy-MM-dd'T'HH:mm:ss", - java.util.Locale.US - ).format(it) + java.time.ZonedDateTime + .ofInstant( + it.toInstant(), + java.time.ZoneId + .systemDefault() + ).format( + java.time.format + .DateTimeFormatter + .ISO_LOCAL_DATE_TIME + ) } ?: "" ) } 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 03190010f2df..300350217ac0 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 @@ -1452,6 +1452,11 @@ class StatsRepository @Inject constructor( } } } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Error fetching subscribers graph", + e + ) SubscribersGraphResult.Error( messageResId = R.string.stats_error_api ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt index 67faf5a077c0..faeef247b847 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.ui.newstats.subscribers.SubscribersCardType @@ -28,6 +30,8 @@ class SubscribersCardsConfigurationRepository @Inject constructor( ) .create() + private val mutex = Mutex() + private val _configurationFlow = MutableStateFlow< Pair?>(null) val configurationFlow: StateFlow< @@ -57,41 +61,52 @@ class SubscribersCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: SubscribersCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val newVisibleCards = - current.visibleCards.toMutableList() - newVisibleCards.remove(cardType) - saveConfiguration( - siteId, - current.copy(visibleCards = newVisibleCards) - ) + mutex.withLock { + val current = loadConfiguration(siteId) + val newVisibleCards = + current.visibleCards.toMutableList() + newVisibleCards.remove(cardType) + saveConfiguration( + siteId, + current.copy( + visibleCards = newVisibleCards + ) + ) + } } suspend fun addCard( siteId: Long, cardType: SubscribersCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - if (cardType in current.visibleCards) return@withContext - val newVisibleCards = - current.visibleCards + cardType - saveConfiguration( - siteId, - current.copy(visibleCards = newVisibleCards) - ) + mutex.withLock { + val current = loadConfiguration(siteId) + if (cardType in current.visibleCards) return@withLock + val newVisibleCards = + current.visibleCards + cardType + saveConfiguration( + siteId, + current.copy( + visibleCards = newVisibleCards + ) + ) + } } suspend fun moveCardUp( siteId: Long, cardType: SubscribersCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = - current.visibleCards.indexOf(cardType) - if (index > 0) { - moveCardToIndex( - siteId, current, cardType, index - 1 - ) + mutex.withLock { + val current = loadConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex( + siteId, current, + cardType, index - 1 + ) + } } } @@ -99,13 +114,15 @@ class SubscribersCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: SubscribersCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = - current.visibleCards.indexOf(cardType) - if (index > 0) { - moveCardToIndex( - siteId, current, cardType, 0 - ) + mutex.withLock { + val current = loadConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index > 0) { + moveCardToIndex( + siteId, current, cardType, 0 + ) + } } } @@ -113,15 +130,18 @@ class SubscribersCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: SubscribersCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = - current.visibleCards.indexOf(cardType) - if (index >= 0 && - index < current.visibleCards.size - 1 - ) { - moveCardToIndex( - siteId, current, cardType, index + 1 - ) + mutex.withLock { + val current = loadConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index >= 0 && + index < current.visibleCards.size - 1 + ) { + moveCardToIndex( + siteId, current, + cardType, index + 1 + ) + } } } @@ -129,16 +149,18 @@ class SubscribersCardsConfigurationRepository @Inject constructor( siteId: Long, cardType: SubscribersCardType ): Unit = withContext(ioDispatcher) { - val current = getConfiguration(siteId) - val index = - current.visibleCards.indexOf(cardType) - if (index >= 0 && - index < current.visibleCards.size - 1 - ) { - moveCardToIndex( - siteId, current, cardType, - current.visibleCards.size - 1 - ) + mutex.withLock { + val current = loadConfiguration(siteId) + val index = + current.visibleCards.indexOf(cardType) + if (index >= 0 && + index < current.visibleCards.size - 1 + ) { + moveCardToIndex( + siteId, current, cardType, + current.visibleCards.size - 1 + ) + } } } @@ -194,13 +216,17 @@ class SubscribersCardsConfigurationRepository @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun isValidConfiguration( config: SubscribersCardsConfiguration ): Boolean { - val validCards = config.visibleCards - .filterIsInstance() - return validCards.size == - config.visibleCards.size + return try { + config.visibleCards.all { + it in SubscribersCardType.entries + } + } catch (_: Exception) { + false + } } private fun resetToDefault( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt index dca15d8f9aaf..403f25dcf122 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt @@ -57,7 +57,8 @@ abstract class BaseSubscribersCardViewModel( if (accessToken.isNullOrEmpty()) return statsRepository.init(accessToken) resetLoadedSuccessfully() - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { try { _isRefreshing.value = true fetchData(site.siteId) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt index 78ae29dc5fe2..39b125205064 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt @@ -117,6 +117,10 @@ fun SubscribersTabContent( val addCardSheetState = rememberModalBottomSheetState() + var previousVisibleCards by remember { + mutableStateOf?>(null) + } + LaunchedEffect(cardsToLoad) { cardsToLoad.dispatchToVisibleCards( onAllTimeStats = { @@ -135,6 +139,23 @@ fun SubscribersTabContent( ) } + LaunchedEffect(visibleCards) { + val previous = previousVisibleCards + previousVisibleCards = visibleCards + if (previous == null) return@LaunchedEffect + val newCards = visibleCards - previous.toSet() + newCards.dispatchToVisibleCards( + onAllTimeStats = { + allTimeViewModel.loadData() + }, + onGraph = { graphViewModel.loadData() }, + onSubscribersList = { + subscribersListViewModel.loadData() + }, + onEmails = { emailsViewModel.loadData() } + ) + } + if (showAddCardSheet) { AddSubscribersCardBottomSheet( sheetState = addCardSheetState, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt index 271364c0162d..c55be95247fe 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt @@ -15,7 +15,7 @@ import org.wordpress.android.ui.newstats.repository.EmailsStatsResult import org.wordpress.android.ui.newstats.repository.StatsRepository import javax.inject.Inject -private const val PAGE_SIZE = 20 +internal const val EMAILS_DETAIL_PAGE_SIZE = 20 @HiltViewModel class EmailsDetailViewModel @Inject constructor( @@ -52,7 +52,7 @@ class EmailsDetailViewModel @Inject constructor( viewModelScope.launch { paginationMutex.withLock { if (_items.value.isNotEmpty()) return@launch - currentQuantity = PAGE_SIZE + currentQuantity = EMAILS_DETAIL_PAGE_SIZE _isLoading.value = true _hasError.value = false _canLoadMore.value = true @@ -69,7 +69,7 @@ class EmailsDetailViewModel @Inject constructor( _isLoadingMore.value ) return@launch _isLoadingMore.value = true - currentQuantity += PAGE_SIZE + currentQuantity += EMAILS_DETAIL_PAGE_SIZE fetchEmails( currentQuantity, isInitial = false ) @@ -112,7 +112,7 @@ class EmailsDetailViewModel @Inject constructor( _hasError.value = true _canLoadMore.value = false } else { - currentQuantity -= PAGE_SIZE + currentQuantity -= EMAILS_DETAIL_PAGE_SIZE } } } @@ -121,7 +121,7 @@ class EmailsDetailViewModel @Inject constructor( _hasError.value = true _canLoadMore.value = false } else { - currentQuantity -= PAGE_SIZE + currentQuantity -= EMAILS_DETAIL_PAGE_SIZE } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt index 802037b17fb2..3460d0021859 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt @@ -6,8 +6,7 @@ import org.wordpress.android.R sealed class SubscribersGraphUiState { data object Loading : SubscribersGraphUiState() data class Loaded( - val dataPoints: List, - val selectedTab: SubscribersGraphTab + val dataPoints: List ) : SubscribersGraphUiState() data class Error( val message: String, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt index 7d603edd2a2e..3bc8aaa4e032 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt @@ -75,8 +75,7 @@ class SubscribersGraphViewModel @Inject constructor( ), count = it.count ) - }, - selectedTab = tab + } ) ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt index c61b9fec458f..b06ec06d2af8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt @@ -267,30 +267,43 @@ internal fun formatSubscriberDate( .ISO_LOCAL_DATE ) } - val days = java.time.temporal.ChronoUnit.DAYS - .between(subscribed, java.time.LocalDate.now()) + val today = java.time.LocalDate.now() + val period = java.time.Period.between( + subscribed, today + ) + val totalDays = + java.time.temporal.ChronoUnit.DAYS + .between(subscribed, today) when { - days < 1L -> resources.getString( + totalDays < 1L -> resources.getString( R.string.stats_subscriber_since_today ) - days < DAYS_IN_YEAR -> resources + period.years < 1 -> resources .getQuantityString( R.plurals.stats_subscriber_days, - days.toInt(), days.toInt() + totalDays.toInt(), totalDays.toInt() ) else -> { - val years = days / DAYS_IN_YEAR - val remaining = days % DAYS_IN_YEAR + val years = period.years + val remaining = + java.time.temporal.ChronoUnit.DAYS + .between( + subscribed.plusYears( + years.toLong() + ), + today + ) val yearsPart = resources .getQuantityString( R.plurals.stats_subscriber_years, - years.toInt(), years.toInt() + years, years ) if (remaining == 0L) yearsPart else { val daysPart = resources .getQuantityString( - R.plurals.stats_subscriber_days, + R.plurals + .stats_subscriber_days, remaining.toInt(), remaining.toInt() ) @@ -306,5 +319,3 @@ internal fun formatSubscriberDate( dateString } } - -private const val DAYS_IN_YEAR = 365L diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt index 3dca0c52e122..184338237d1a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt @@ -37,7 +37,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -90,11 +89,10 @@ private fun SubscribersListDetailScreen( viewModel.canLoadMore.collectAsState() val hasError by viewModel.hasError.collectAsState() - val resources = LocalContext.current.resources val listState = rememberLazyListState() LaunchedEffect(Unit) { - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() } val shouldLoadMore by remember { @@ -112,7 +110,7 @@ private fun SubscribersListDetailScreen( } LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore) viewModel.loadMore(resources) + if (shouldLoadMore) viewModel.loadMore() } val title = stringResource( @@ -153,7 +151,7 @@ private fun SubscribersListDetailScreen( .fillMaxSize() .padding(contentPadding), onRetry = { - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() } ) } else { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt index e21cca622f35..26a0fb823eca 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt @@ -1,6 +1,5 @@ package org.wordpress.android.ui.newstats.subscribers.subscriberslist -import android.content.res.Resources import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -14,15 +13,17 @@ 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.SubscribersListResult +import org.wordpress.android.viewmodel.ContextProvider import javax.inject.Inject -private const val PAGE_SIZE = 20 +internal const val SUBSCRIBERS_DETAIL_PAGE_SIZE = 20 @HiltViewModel class SubscribersListDetailViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, - private val statsRepository: StatsRepository + private val statsRepository: StatsRepository, + private val contextProvider: ContextProvider ) : ViewModel() { private val _items = MutableStateFlow>( emptyList() @@ -49,7 +50,7 @@ class SubscribersListDetailViewModel @Inject constructor( private var currentPage = 0 private val paginationMutex = Mutex() - fun loadInitialPage(resources: Resources) { + fun loadInitialPage() { viewModelScope.launch { paginationMutex.withLock { if (_items.value.isNotEmpty()) return@launch @@ -57,17 +58,13 @@ class SubscribersListDetailViewModel @Inject constructor( _isLoading.value = true _hasError.value = false _canLoadMore.value = true - fetchPage( - currentPage, - isInitial = true, - resources = resources - ) + fetchPage(currentPage, isInitial = true) _isLoading.value = false } } } - fun loadMore(resources: Resources) { + fun loadMore() { viewModelScope.launch { paginationMutex.withLock { if (!_canLoadMore.value || @@ -75,11 +72,7 @@ class SubscribersListDetailViewModel @Inject constructor( ) return@launch _isLoadingMore.value = true currentPage++ - fetchPage( - currentPage, - isInitial = false, - resources = resources - ) + fetchPage(currentPage, isInitial = false) _isLoadingMore.value = false } } @@ -88,8 +81,7 @@ class SubscribersListDetailViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") private suspend fun fetchPage( page: Int, - isInitial: Boolean, - resources: Resources + isInitial: Boolean ) { val siteId = selectedSiteRepository .getSelectedSite()?.siteId ?: return @@ -97,10 +89,12 @@ class SubscribersListDetailViewModel @Inject constructor( if (accessToken.isNullOrEmpty()) return statsRepository.init(accessToken) + val resources = + contextProvider.getContext().resources try { val result = statsRepository.fetchSubscribersList( siteId = siteId, - perPage = PAGE_SIZE, + perPage = SUBSCRIBERS_DETAIL_PAGE_SIZE, page = page ) when (result) { @@ -124,7 +118,8 @@ class SubscribersListDetailViewModel @Inject constructor( _items.value + newItems } _canLoadMore.value = - newItems.size == PAGE_SIZE + newItems.size == + SUBSCRIBERS_DETAIL_PAGE_SIZE } is SubscribersListResult.Error -> { if (isInitial) { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt index 8de5ec77d93b..396571d4d89f 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt @@ -258,6 +258,17 @@ class AllTimeSubscribersViewModelTest : BaseUnitTest() { assertThat(state.count90DaysAgo).isEqualTo(0L) } + @Test + fun `when data loads, then statsRepository init is called with token`() = test { + whenever(statsRepository.fetchSubscribersAllTime(any())) + .thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + verify(statsRepository).init(eq(TEST_ACCESS_TOKEN)) + } + private fun createSuccessResult() = SubscribersAllTimeResult.Success( currentCount = TEST_CURRENT, count30DaysAgo = TEST_30D, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt index ec8019600e84..abf4e68c231d 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt @@ -257,6 +257,17 @@ class EmailsCardViewModelTest : BaseUnitTest() { assertThat(state.items[0].clicks).isEqualTo(42L) } + @Test + fun `when data loads, then statsRepository init is called with token`() = test { + whenever(statsRepository.fetchEmailsSummary(any(), any())) + .thenReturn(EmailsStatsResult.Success(createTestItems())) + + initViewModel() + advanceUntilIdle() + + verify(statsRepository).init(eq(TEST_ACCESS_TOKEN)) + } + private fun createTestItems() = listOf( EmailItemData(title = "Email 1", opens = 100L, clicks = 10L), EmailItemData(title = "Email 2", opens = 200L, clicks = 20L) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt index e99ed04fe7ae..abb8cf4c98a9 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt @@ -307,6 +307,7 @@ class EmailsDetailViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L private const val TEST_ACCESS_TOKEN = "test_access_token" - private const val PAGE_SIZE = 20 + private const val PAGE_SIZE = + EMAILS_DETAIL_PAGE_SIZE } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt index e8bb4fd3580a..f2cc1cdf1960 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt @@ -134,8 +134,6 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { val loaded = state as SubscribersGraphUiState.Loaded assertThat(loaded.dataPoints).hasSize(3) - assertThat(loaded.selectedTab) - .isEqualTo(SubscribersGraphTab.DAYS) } @Test @@ -531,6 +529,22 @@ class SubscribersGraphViewModelTest : BaseUnitTest() { assertThat(state.isAuthError).isTrue() } + @Test + fun `when data loads, then statsRepository init is called with token`() = + test { + whenever( + statsRepository.fetchSubscribersGraph( + any(), any(), any(), any() + ) + ).thenReturn(createSuccessResult()) + + initViewModel() + advanceUntilIdle() + + verify(statsRepository) + .init(eq(TEST_ACCESS_TOKEN)) + } + private fun createSuccessResult() = SubscribersGraphResult.Success( dataPoints = listOf( diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt index d90e002a2d2b..145ea5b7e637 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt @@ -108,35 +108,65 @@ class FormatSubscriberDateTest { @Test fun `when subscribed exactly 1 year ago, then returns 1 year`() { + val subscribed = LocalDate.now().minusYears(1) + val dateStr = subscribed.format( + DateTimeFormatter.ISO_LOCAL_DATE + ) val result = formatSubscriberDate( - dateNDaysAgo(365), resources + dateStr, resources ) assertThat(result).isEqualTo("1 year") } @Test fun `when subscribed 1 year and 1 day ago, then returns years and days`() { + val subscribed = LocalDate.now().minusYears(1) + .minusDays(1) + val dateStr = subscribed.format( + DateTimeFormatter.ISO_LOCAL_DATE + ) val result = formatSubscriberDate( - dateNDaysAgo(366), resources + dateStr, resources ) assertThat(result).isEqualTo("1 year, 1 day") } @Test fun `when subscribed 2 years ago, then returns 2 years`() { + val subscribed = LocalDate.now().minusYears(2) + val dateStr = subscribed.format( + DateTimeFormatter.ISO_LOCAL_DATE + ) val result = formatSubscriberDate( - dateNDaysAgo(730), resources + dateStr, resources ) assertThat(result).isEqualTo("2 years") } @Test - fun `when subscribed 2 years and 100 days ago, then returns years and days`() { + fun `when subscribed more than 2 years ago, then returns years and days`() { + val today = LocalDate.now() + val twoYearsAgo = today.minusYears(2) + val subscribed = twoYearsAgo.minusDays(50) + val dateStr = subscribed.format( + DateTimeFormatter.ISO_LOCAL_DATE + ) + val period = java.time.Period.between( + subscribed, today + ) + val remaining = + java.time.temporal.ChronoUnit.DAYS.between( + subscribed.plusYears( + period.years.toLong() + ), + today + ) val result = formatSubscriberDate( - dateNDaysAgo(830), resources + dateStr, resources + ) + assertThat(result).isEqualTo( + "${period.years} years, $remaining days" ) - assertThat(result) - .isEqualTo("2 years, 100 days") } @Test diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt index 627524bc0eef..9ba0cd0b842b 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.newstats.subscribers.subscriberslist +import android.content.Context import android.content.res.Resources import kotlinx.coroutines.ExperimentalCoroutinesApi import org.assertj.core.api.Assertions.assertThat @@ -20,6 +21,7 @@ import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.SubscriberItemData import org.wordpress.android.ui.newstats.repository.SubscribersListResult +import org.wordpress.android.viewmodel.ContextProvider @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner.Silent::class) @@ -33,6 +35,12 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { @Mock private lateinit var statsRepository: StatsRepository + @Mock + private lateinit var contextProvider: ContextProvider + + @Mock + private lateinit var context: Context + @Mock private lateinit var resources: Resources @@ -50,10 +58,14 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { .thenReturn(testSite) whenever(accountStore.accessToken) .thenReturn(TEST_ACCESS_TOKEN) + whenever(contextProvider.getContext()) + .thenReturn(context) + whenever(context.resources).thenReturn(resources) viewModel = SubscribersListDetailViewModel( selectedSiteRepository, accountStore, - statsRepository + statsRepository, + contextProvider ) } @@ -68,7 +80,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(3)) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() assertThat(viewModel.items.value).hasSize(3) @@ -86,7 +98,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(PAGE_SIZE)) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() assertThat(viewModel.canLoadMore.value).isTrue() @@ -103,7 +115,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(5)) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() assertThat(viewModel.canLoadMore.value).isFalse() @@ -129,10 +141,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() - viewModel.loadMore(resources) + viewModel.loadMore() advanceUntilIdle() assertThat(viewModel.items.value) @@ -158,10 +170,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(3)) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() - viewModel.loadMore(resources) + viewModel.loadMore() advanceUntilIdle() assertThat(viewModel.canLoadMore.value).isFalse() @@ -180,7 +192,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() @@ -206,10 +218,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Error(messageResId = 0) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() - viewModel.loadMore(resources) + viewModel.loadMore() advanceUntilIdle() // Items unchanged from first page @@ -229,10 +241,10 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { SubscribersListResult.Success(createItems(3)) ) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() verify(statsRepository, times(1)) @@ -245,7 +257,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { whenever(selectedSiteRepository.getSelectedSite()) .thenReturn(null) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() @@ -256,7 +268,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { test { whenever(accountStore.accessToken).thenReturn("") - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() @@ -277,7 +289,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ).thenReturn(SubscribersListResult.Success(items)) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() val item = viewModel.items.value[0] @@ -296,7 +308,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { ) ).thenThrow(RuntimeException("Test exception")) - viewModel.loadInitialPage(resources) + viewModel.loadInitialPage() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() @@ -316,6 +328,7 @@ class SubscribersListDetailViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L private const val TEST_ACCESS_TOKEN = "test_access_token" - private const val PAGE_SIZE = 20 + private const val PAGE_SIZE = + SUBSCRIBERS_DETAIL_PAGE_SIZE } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt index 53442107c3df..69ff109e8ca2 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt @@ -253,6 +253,17 @@ class SubscribersListViewModelTest : BaseUnitTest() { assertThat(state.items[0].subscribedSince).isEqualTo("2024-06-15T10:00:00") } + @Test + fun `when data loads, then statsRepository init is called with token`() = test { + whenever(statsRepository.fetchSubscribersList(any(), any(), any())) + .thenReturn(SubscribersListResult.Success(createTestItems())) + + initViewModel() + advanceUntilIdle() + + verify(statsRepository).init(eq(TEST_ACCESS_TOKEN)) + } + private fun createTestItems() = listOf( SubscriberItemData(displayName = "User 1", subscribedSince = "2024-01-01"), SubscriberItemData(displayName = "User 2", subscribedSince = "2024-01-02") From 5b4802db49d6f449da58040bc2c2cfdf4ab13a43 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 3 Mar 2026 12:48:15 +0100 Subject: [PATCH 16/21] Fix detekt issues: refactor long methods, remove unused imports, add suppressions Co-Authored-By: Claude Opus 4.6 --- .../ui/newstats/repository/StatsRepository.kt | 103 +++++++----------- .../alltimestats/AllTimeSubscribersCard.kt | 2 - .../newstats/subscribers/emails/EmailsCard.kt | 1 - .../subscribersgraph/SubscribersGraphCard.kt | 1 + .../subscriberslist/SubscribersListCard.kt | 87 ++++++++------- 5 files changed, 86 insertions(+), 108 deletions(-) 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 300350217ac0..2052d3250a2c 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 @@ -1334,55 +1334,8 @@ class StatsRepository @Inject constructor( suspend fun fetchSubscribersAllTime( siteId: Long ): SubscribersAllTimeResult = withContext(ioDispatcher) { - val today = java.time.LocalDate.now() - val dateFormat = - java.time.format.DateTimeFormatter.ISO_LOCAL_DATE - val todayStr = today.format(dateFormat) - val d30Str = today.minusDays(30) - .format(dateFormat) - val d60Str = today.minusDays(60) - .format(dateFormat) - val d90Str = today.minusDays(90) - .format(dateFormat) - - val (current, d30, d60, d90) = coroutineScope { - val currentDef = async { - statsDataSource.fetchStatsSubscribers( - siteId, - quantity = 1, - date = todayStr - ) - } - val d30Def = async { - statsDataSource.fetchStatsSubscribers( - siteId, - quantity = 1, - date = d30Str - ) - } - val d60Def = async { - statsDataSource.fetchStatsSubscribers( - siteId, - quantity = 1, - date = d60Str - ) - } - val d90Def = async { - statsDataSource.fetchStatsSubscribers( - siteId, - quantity = 1, - date = d90Str - ) - } - listOf( - currentDef.await(), - d30Def.await(), - d60Def.await(), - d90Def.await() - ) - } + val results = fetchAllTimeResults(siteId) - val results = listOf(current, d30, d60, d90) val firstError = results.filterIsInstance< StatsSubscribersDataResult.Error>().firstOrNull() if (firstError != null) { @@ -1394,23 +1347,51 @@ class StatsRepository @Inject constructor( ) } - fun extractCount( - result: StatsSubscribersDataResult - ): Long { - val data = (result as - StatsSubscribersDataResult.Success).data - return data.subscribersData - .firstOrNull()?.count ?: 0L - } - + val current = results[0] + val d30 = results[1] + val d60 = results[2] + val d90 = results[3] SubscribersAllTimeResult.Success( - currentCount = extractCount(current), - count30DaysAgo = extractCount(d30), - count60DaysAgo = extractCount(d60), - count90DaysAgo = extractCount(d90) + currentCount = extractSubscriberCount(current), + count30DaysAgo = extractSubscriberCount(d30), + count60DaysAgo = extractSubscriberCount(d60), + count90DaysAgo = extractSubscriberCount(d90) ) } + @Suppress("MagicNumber") + private suspend fun fetchAllTimeResults( + siteId: Long + ): List { + val today = java.time.LocalDate.now() + val dateFormat = + java.time.format.DateTimeFormatter + .ISO_LOCAL_DATE + val dates = listOf(0L, 30L, 60L, 90L).map { + today.minusDays(it).format(dateFormat) + } + return coroutineScope { + dates.map { date -> + async { + statsDataSource.fetchStatsSubscribers( + siteId, + quantity = 1, + date = date + ) + } + }.map { it.await() } + } + } + + private fun extractSubscriberCount( + result: StatsSubscribersDataResult + ): Long { + val data = (result as + StatsSubscribersDataResult.Success).data + return data.subscribersData + .firstOrNull()?.count ?: 0L + } + /** * Fetches subscriber graph data for a given time unit. */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt index b522ed3fdfb1..e13d06ebad5f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource @@ -21,7 +20,6 @@ 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.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.util.ShimmerBox diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt index fd637c90ffdc..4e40251d3c14 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt @@ -28,7 +28,6 @@ import org.wordpress.android.ui.newstats.components.StatsCardErrorContent import org.wordpress.android.ui.newstats.components.StatsCardHeader import org.wordpress.android.ui.newstats.util.ShimmerBox import org.wordpress.android.ui.newstats.util.formatEmailStat -import org.wordpress.android.ui.newstats.util.formatStatValue private val CardPadding = 16.dp private const val LOADING_SHIMMER_ITEM_COUNT = 5 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt index 078fc3214147..41391923d28b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt @@ -348,6 +348,7 @@ private fun SubscribersChart( private class SubscribersMarkerValueFormatter( private val dataPoints: List ) : DefaultCartesianMarker.ValueFormatter { + @Suppress("ReturnCount") override fun format( context: CartesianDrawingContext, targets: List diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt index b06ec06d2af8..f350bb1010eb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt @@ -254,19 +254,7 @@ internal fun formatSubscriberDate( resources: android.content.res.Resources ): String { return try { - val subscribed = try { - java.time.LocalDateTime.parse( - dateString, - java.time.format.DateTimeFormatter - .ISO_DATE_TIME - ).toLocalDate() - } catch (_: Exception) { - java.time.LocalDate.parse( - dateString, - java.time.format.DateTimeFormatter - .ISO_LOCAL_DATE - ) - } + val subscribed = parseSubscriberDate(dateString) val today = java.time.LocalDate.now() val period = java.time.Period.between( subscribed, today @@ -283,39 +271,50 @@ internal fun formatSubscriberDate( R.plurals.stats_subscriber_days, totalDays.toInt(), totalDays.toInt() ) - else -> { - val years = period.years - val remaining = - java.time.temporal.ChronoUnit.DAYS - .between( - subscribed.plusYears( - years.toLong() - ), - today - ) - val yearsPart = resources - .getQuantityString( - R.plurals.stats_subscriber_years, - years, years - ) - if (remaining == 0L) yearsPart - else { - val daysPart = resources - .getQuantityString( - R.plurals - .stats_subscriber_days, - remaining.toInt(), - remaining.toInt() - ) - resources.getString( - R.string - .stats_subscriber_years_and_days, - yearsPart, daysPart - ) - } - } + else -> formatYearsAndDays( + subscribed, today, period, resources + ) } } catch (_: Exception) { dateString } } + +private fun parseSubscriberDate( + dateString: String +): java.time.LocalDate = try { + java.time.LocalDateTime.parse( + dateString, + java.time.format.DateTimeFormatter.ISO_DATE_TIME + ).toLocalDate() +} catch (_: Exception) { + java.time.LocalDate.parse( + dateString, + java.time.format.DateTimeFormatter.ISO_LOCAL_DATE + ) +} + +private fun formatYearsAndDays( + subscribed: java.time.LocalDate, + today: java.time.LocalDate, + period: java.time.Period, + resources: android.content.res.Resources +): String { + val years = period.years + val remaining = + java.time.temporal.ChronoUnit.DAYS.between( + subscribed.plusYears(years.toLong()), today + ) + val yearsPart = resources.getQuantityString( + R.plurals.stats_subscriber_years, years, years + ) + if (remaining == 0L) return yearsPart + val daysPart = resources.getQuantityString( + R.plurals.stats_subscriber_days, + remaining.toInt(), remaining.toInt() + ) + return resources.getString( + R.string.stats_subscriber_years_and_days, + yearsPart, daysPart + ) +} From e75276d31f5cd669e18dc305cb3ba1752b3f5c18 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 3 Mar 2026 13:25:59 +0100 Subject: [PATCH 17/21] Fix race condition, security alert, and add base ViewModel tests - Add @Synchronized to StatsDataSourceImpl.getOrCreateClient() to prevent race condition during parallel API calls - Move date formatting from SubscribersListCard composable to SubscribersListViewModel to avoid stale resources capture - Make saveConfiguration private in ConfigurationRepository - Add comment documenting emails pagination API limitation - Add BaseSubscribersCardViewModelTest with 12 tests Co-Authored-By: Claude Opus 4.6 --- .../datasource/StatsDataSourceImpl.kt | 1 + ...SubscribersCardsConfigurationRepository.kt | 2 +- .../emails/EmailsDetailViewModel.kt | 4 + .../subscriberslist/SubscribersListCard.kt | 12 +- .../SubscribersListViewModel.kt | 13 +- ...cribersCardsConfigurationRepositoryTest.kt | 23 +- .../BaseSubscribersCardViewModelTest.kt | 326 ++++++++++++++++++ .../SubscribersListViewModelTest.kt | 27 +- 8 files changed, 380 insertions(+), 28 deletions(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt 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 7e673b9becbe..8d616597c084 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 @@ -64,6 +64,7 @@ class StatsDataSourceImpl @Inject constructor( @Volatile private var wpComApiClient: WpComApiClient? = null + @Synchronized private fun getOrCreateClient(): WpComApiClient { val token = accessToken check(token != null) { "DataSource not initialized" } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt index faeef247b847..ebb551cc9b98 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt @@ -45,7 +45,7 @@ class SubscribersCardsConfigurationRepository @Inject constructor( loadConfiguration(siteId) } - suspend fun saveConfiguration( + private suspend fun saveConfiguration( siteId: Long, configuration: SubscribersCardsConfiguration ): Unit = withContext(ioDispatcher) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt index c55be95247fe..eba0bbbbdf51 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt @@ -62,6 +62,10 @@ class EmailsDetailViewModel @Inject constructor( } } + // Note: The emails API only supports a "quantity" + // parameter (not page/offset), so each "load more" + // re-fetches all items with an increased quantity. + // This is a known API limitation. fun loadMore() { viewModelScope.launch { paginationMutex.withLock { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt index f350bb1010eb..1cd8d86fb9d1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt @@ -11,9 +11,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -230,16 +228,8 @@ private fun SubscriberItemRow( modifier = Modifier.weight(1f) ) Spacer(modifier = Modifier.width(12.dp)) - val resources = LocalContext.current.resources - val formattedDate = remember( - item.subscribedSince - ) { - formatSubscriberDate( - item.subscribedSince, resources - ) - } Text( - text = formattedDate, + text = item.formattedDate, style = MaterialTheme .typography.bodySmall, color = MaterialTheme diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt index 77402f1ffd36..247d9c3f72e8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt @@ -6,6 +6,7 @@ import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.SubscribersListResult import org.wordpress.android.ui.newstats.subscribers.BaseSubscribersCardViewModel +import org.wordpress.android.viewmodel.ContextProvider import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject @@ -16,7 +17,8 @@ class SubscribersListViewModel @Inject constructor( selectedSiteRepository: SelectedSiteRepository, accountStore: AccountStore, statsRepository: StatsRepository, - resourceProvider: ResourceProvider + resourceProvider: ResourceProvider, + private val contextProvider: ContextProvider ) : BaseSubscribersCardViewModel( selectedSiteRepository, accountStore, @@ -40,6 +42,8 @@ class SubscribersListViewModel @Inject constructor( ) { is SubscribersListResult.Success -> { markLoadedSuccessfully() + val resources = contextProvider + .getContext().resources updateState( SubscribersListUiState.Loaded( items = result.subscribers @@ -49,7 +53,12 @@ class SubscribersListViewModel @Inject constructor( displayName = it.displayName, subscribedSince = - it.subscribedSince + it.subscribedSince, + formattedDate = + formatSubscriberDate( + it.subscribedSince, + resources + ) ) } ) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt index 75ad2b07a6e2..035c07c1413a 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt @@ -77,14 +77,11 @@ class SubscribersCardsConfigurationRepositoryTest : BaseUnitTest() { } @Test - fun `when saveConfiguration is called, then json is saved to prefs`() = test { + fun `when addCard is called, then json is saved to prefs`() = test { whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) - .thenReturn(null) - val config = SubscribersCardsConfiguration( - visibleCards = listOf(SubscribersCardType.ALL_TIME_SUBSCRIBERS) - ) + .thenReturn("""{"visibleCards":["ALL_TIME_SUBSCRIBERS"]}""") - repository.saveConfiguration(TEST_SITE_ID, config) + repository.addCard(TEST_SITE_ID, SubscribersCardType.EMAILS) verify(appPrefsWrapper) .setSubscribersCardsConfigurationJson(eq(TEST_SITE_ID), any()) @@ -297,18 +294,20 @@ class SubscribersCardsConfigurationRepositoryTest : BaseUnitTest() { @Test fun `when configurationFlow emits, then it contains site id and configuration`() = test { whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID)) - .thenReturn(null) - val config = SubscribersCardsConfiguration( - visibleCards = listOf(SubscribersCardType.ALL_TIME_SUBSCRIBERS) - ) + .thenReturn("""{"visibleCards":[]}""") - repository.saveConfiguration(TEST_SITE_ID, config) + repository.addCard( + TEST_SITE_ID, + SubscribersCardType.ALL_TIME_SUBSCRIBERS + ) val flowValue = repository.configurationFlow.value assertThat(flowValue).isNotNull assertThat(flowValue?.first).isEqualTo(TEST_SITE_ID) assertThat(flowValue?.second?.visibleCards) - .containsExactly(SubscribersCardType.ALL_TIME_SUBSCRIBERS) + .containsExactly( + SubscribersCardType.ALL_TIME_SUBSCRIBERS + ) } companion object { diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt new file mode 100644 index 000000000000..a43ea2a4d060 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt @@ -0,0 +1,326 @@ +package org.wordpress.android.ui.newstats.subscribers + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.viewmodel.ResourceProvider + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner.Silent::class) +class BaseSubscribersCardViewModelTest : BaseUnitTest() { + @Mock + private lateinit var selectedSiteRepository: + SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var statsRepository: StatsRepository + + @Mock + private lateinit var resourceProvider: ResourceProvider + + private lateinit var viewModel: TestViewModel + + 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( + resourceProvider.getString( + R.string.stats_error_no_site + ) + ).thenReturn("No site selected") + whenever( + resourceProvider.getString( + R.string.stats_error_not_authenticated + ) + ).thenReturn("Not authenticated") + whenever( + resourceProvider.getString( + R.string.stats_error_unknown + ) + ).thenReturn("Unknown error") + } + + private fun createViewModel(): TestViewModel { + viewModel = TestViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider + ) + return viewModel + } + + @Test + fun `when loadDataIfNeeded succeeds then second call is skipped`() = + test { + createViewModel() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isEqualTo(TestState.Loaded) + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.loadCount).isEqualTo(1) + } + + @Test + fun `when loadData is called after loadDataIfNeeded then data reloads`() = + test { + createViewModel() + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.loadCount).isEqualTo(2) + } + + @Test + fun `when loadData called directly then loadDataIfNeeded still works after`() = + test { + createViewModel() + + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isEqualTo(TestState.Loaded) + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + // Should not reload since already loaded + assertThat(viewModel.loadCount).isEqualTo(1) + } + + @Test + fun `when no site selected then error state is emitted`() = + test { + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + createViewModel() + + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TestState.Error::class.java + ) + } + + @Test + fun `when access token is null then error state is emitted`() = + test { + whenever(accountStore.accessToken) + .thenReturn(null) + createViewModel() + + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TestState.Error::class.java + ) + } + + @Test + fun `when access token is empty then error state is emitted`() = + test { + whenever(accountStore.accessToken) + .thenReturn("") + createViewModel() + + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TestState.Error::class.java + ) + } + + @Test + fun `when loadDataInternal throws then error state is emitted`() = + test { + createViewModel() + viewModel.shouldThrow = true + + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertThat(state).isInstanceOf( + TestState.Error::class.java + ) + assertThat((state as TestState.Error).message) + .isEqualTo("Unknown error") + } + + @Test + fun `when refresh is called then isRefreshing is false after completion`() = + test { + createViewModel() + viewModel.loadData() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.isRefreshing.value).isFalse() + assertThat(viewModel.loadCount).isEqualTo(2) + } + + @Test + fun `when refresh is called then statsRepository init is called`() = + test { + createViewModel() + viewModel.loadData() + advanceUntilIdle() + + viewModel.refresh() + advanceUntilIdle() + + // Once from loadData, once from refresh + verify(statsRepository, times(2)) + .init(eq(TEST_ACCESS_TOKEN)) + } + + @Test + fun `when refresh is called without site then nothing happens`() = + test { + createViewModel() + viewModel.loadData() + advanceUntilIdle() + + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) + viewModel.refresh() + advanceUntilIdle() + + // Only 1 load from the initial loadData call + assertThat(viewModel.loadCount).isEqualTo(1) + } + + @Test + fun `when refresh is called without token then nothing happens`() = + test { + createViewModel() + viewModel.loadData() + advanceUntilIdle() + + whenever(accountStore.accessToken).thenReturn(null) + viewModel.refresh() + advanceUntilIdle() + + assertThat(viewModel.loadCount).isEqualTo(1) + } + + @Test + fun `when loadDataIfNeeded fails then next call retries`() = + test { + createViewModel() + viewModel.shouldThrow = true + + viewModel.loadDataIfNeeded() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isInstanceOf(TestState.Error::class.java) + + // Error means not loaded successfully, but + // isLoading was reset in the finally block. + // loadData via loadDataIfNeeded should retry. + viewModel.shouldThrow = false + viewModel.loadData() + advanceUntilIdle() + + assertThat(viewModel.uiState.value) + .isEqualTo(TestState.Loaded) + } + + sealed class TestState { + data object Loading : TestState() + data class Error( + val message: String, + val isAuthError: Boolean = false + ) : TestState() + data object Loaded : TestState() + } + + class TestViewModel( + selectedSiteRepository: SelectedSiteRepository, + accountStore: AccountStore, + statsRepository: StatsRepository, + resourceProvider: ResourceProvider + ) : BaseSubscribersCardViewModel( + selectedSiteRepository, + accountStore, + statsRepository, + resourceProvider, + TestState.Loading + ) { + override val loadingState = TestState.Loading + var loadCount = 0 + var shouldThrow = false + + override fun errorState( + message: String, + isAuthError: Boolean + ) = TestState.Error(message, isAuthError) + + override suspend fun loadDataInternal( + siteId: Long + ) { + if (shouldThrow) { + throw RuntimeException("Test error") + } + loadCount++ + markLoadedSuccessfully() + updateState(TestState.Loaded) + } + } + + companion object { + private const val TEST_SITE_ID = 123L + private const val TEST_ACCESS_TOKEN = + "test_access_token" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt index 69ff109e8ca2..699aa696ee23 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt @@ -20,6 +20,7 @@ import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.ui.newstats.repository.SubscriberItemData import org.wordpress.android.ui.newstats.repository.SubscribersListResult +import org.wordpress.android.viewmodel.ContextProvider import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi @@ -37,6 +38,15 @@ class SubscribersListViewModelTest : BaseUnitTest() { @Mock private lateinit var resourceProvider: ResourceProvider + @Mock + private lateinit var contextProvider: ContextProvider + + @Mock + private lateinit var context: android.content.Context + + @Mock + private lateinit var resources: android.content.res.Resources + private lateinit var viewModel: SubscribersListViewModel private val testSite = SiteModel().apply { @@ -58,6 +68,17 @@ class SubscribersListViewModelTest : BaseUnitTest() { ).thenReturn("Not authenticated") whenever(resourceProvider.getString(R.string.stats_error_unknown)) .thenReturn("Unknown error") + whenever(contextProvider.getContext()).thenReturn(context) + whenever(context.resources).thenReturn(resources) + whenever( + resources.getQuantityString(any(), any(), any()) + ).thenReturn("1 year") + whenever( + resources.getString( + eq(R.string.stats_subscriber_years_and_days), + any(), any() + ) + ).thenReturn("1 year, 1 day") } private fun initViewModel() { @@ -65,7 +86,8 @@ class SubscribersListViewModelTest : BaseUnitTest() { selectedSiteRepository, accountStore, statsRepository, - resourceProvider + resourceProvider, + contextProvider ) viewModel.loadData() } @@ -193,7 +215,8 @@ class SubscribersListViewModelTest : BaseUnitTest() { selectedSiteRepository, accountStore, statsRepository, - resourceProvider + resourceProvider, + contextProvider ) viewModel.loadDataIfNeeded() advanceUntilIdle() From 0dfd7b2a0aa66d74f02b691f327076fc43ec6b12 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 3 Mar 2026 14:20:59 +0100 Subject: [PATCH 18/21] Fix critical coroutine safety, unsafe casts, and pagination bugs - Rethrow CancellationException in all catch blocks to preserve structured concurrency (BaseSubscribersCardViewModel, detail VMs, StatsRepository) - Use safe cast in extractSubscriberCount to prevent runtime crash - Add try-catch to fetchSubscribersList and fetchEmailsSummary - Add mutex to getConfiguration() to fix TOCTOU race - Fix refresh() bypassing isLoading guard - Remove emails pagination (API limitation) and use static list - Fix premature loadMore trigger by initializing canLoadMore to false - Add !isLoading guard to shouldLoadMore derived state Co-Authored-By: Claude Opus 4.6 --- .../ui/newstats/repository/StatsRepository.kt | 136 +++++++----- ...SubscribersCardsConfigurationRepository.kt | 4 +- .../BaseSubscribersCardViewModel.kt | 5 + .../emails/EmailsDetailActivity.kt | 53 +---- .../emails/EmailsDetailViewModel.kt | 127 ++++-------- .../SubscribersListDetailActivity.kt | 4 +- .../SubscribersListDetailViewModel.kt | 39 ++-- ...cribersCardsConfigurationRepositoryTest.kt | 1 - .../BaseSubscribersCardViewModelTest.kt | 3 +- .../emails/EmailsDetailViewModelTest.kt | 195 ++++-------------- 10 files changed, 209 insertions(+), 358 deletions(-) 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 2052d3250a2c..e92aa0e54526 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 @@ -36,6 +36,7 @@ import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Named +import kotlin.coroutines.cancellation.CancellationException private const val HOURLY_QUANTITY = 24 private const val DAILY_QUANTITY = 1 @@ -1386,8 +1387,9 @@ class StatsRepository @Inject constructor( private fun extractSubscriberCount( result: StatsSubscribersDataResult ): Long { - val data = (result as - StatsSubscribersDataResult.Success).data + val data = (result as? + StatsSubscribersDataResult.Success)?.data + ?: return 0L return data.subscribersData .firstOrNull()?.count ?: 0L } @@ -1447,69 +1449,107 @@ class StatsRepository @Inject constructor( /** * Fetches a list of subscribers for the given site. */ + @Suppress("TooGenericExceptionCaught") suspend fun fetchSubscribersList( siteId: Long, perPage: Int = SUBSCRIBERS_DEFAULT_MAX, page: Int = 1 ): SubscribersListResult = withContext(ioDispatcher) { - when ( - val result = statsDataSource - .fetchSubscribersByUserType( - siteId, perPage, page - ) - ) { - is SubscribersByUserTypeDataResult.Success -> { - SubscribersListResult.Success( - subscribers = result.items.map { - SubscriberItemData( - displayName = it.displayName, - subscribedSince = - it.subscribedSince - ) - } - ) - } - is SubscribersByUserTypeDataResult.Error -> { - SubscribersListResult.Error( - messageResId = - result.errorType.messageResId, - isAuthError = result.errorType == - StatsErrorType.AUTH_ERROR - ) + try { + when ( + val result = statsDataSource + .fetchSubscribersByUserType( + siteId, perPage, page + ) + ) { + is SubscribersByUserTypeDataResult + .Success -> { + SubscribersListResult.Success( + subscribers = + result.items.map { + SubscriberItemData( + displayName = + it.displayName, + subscribedSince = + it.subscribedSince + ) + } + ) + } + is SubscribersByUserTypeDataResult + .Error -> { + SubscribersListResult.Error( + messageResId = + result.errorType.messageResId, + isAuthError = + result.errorType == + StatsErrorType.AUTH_ERROR + ) + } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Error fetching subscribers list", + e + ) + SubscribersListResult.Error( + messageResId = R.string.stats_error_api + ) } } /** * Fetches email stats summary for the given site. */ + @Suppress("TooGenericExceptionCaught") suspend fun fetchEmailsSummary( siteId: Long, quantity: Int = SUBSCRIBERS_DEFAULT_MAX ): EmailsStatsResult = withContext(ioDispatcher) { - when ( - val result = statsDataSource - .fetchStatsEmailsSummary(siteId, quantity) - ) { - is StatsEmailsSummaryDataResult.Success -> { - EmailsStatsResult.Success( - items = result.items.map { - EmailItemData( - title = it.title, - opens = it.opens, - clicks = it.clicks - ) - } - ) - } - is StatsEmailsSummaryDataResult.Error -> { - EmailsStatsResult.Error( - messageResId = - result.errorType.messageResId, - isAuthError = result.errorType == - StatsErrorType.AUTH_ERROR - ) + try { + when ( + val result = statsDataSource + .fetchStatsEmailsSummary( + siteId, quantity + ) + ) { + is StatsEmailsSummaryDataResult + .Success -> { + EmailsStatsResult.Success( + items = result.items.map { + EmailItemData( + title = it.title, + opens = it.opens, + clicks = it.clicks + ) + } + ) + } + is StatsEmailsSummaryDataResult + .Error -> { + EmailsStatsResult.Error( + messageResId = + result.errorType.messageResId, + isAuthError = + result.errorType == + StatsErrorType.AUTH_ERROR + ) + } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Error fetching emails summary", + e + ) + EmailsStatsResult.Error( + messageResId = R.string.stats_error_api + ) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt index ebb551cc9b98..a6666cf24685 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt @@ -42,7 +42,9 @@ class SubscribersCardsConfigurationRepository @Inject constructor( siteId: Long ): SubscribersCardsConfiguration = withContext(ioDispatcher) { - loadConfiguration(siteId) + mutex.withLock { + loadConfiguration(siteId) + } } private suspend fun saveConfiguration( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt index 403f25dcf122..cd6dd053c1e7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt @@ -14,6 +14,7 @@ import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ResourceProvider import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException @Suppress("TooGenericExceptionCaught") abstract class BaseSubscribersCardViewModel( @@ -57,6 +58,7 @@ abstract class BaseSubscribersCardViewModel( if (accessToken.isNullOrEmpty()) return statsRepository.init(accessToken) resetLoadedSuccessfully() + isLoading.set(true) loadJob?.cancel() loadJob = viewModelScope.launch { try { @@ -64,6 +66,7 @@ abstract class BaseSubscribersCardViewModel( fetchData(site.siteId) } finally { _isRefreshing.value = false + isLoading.set(false) } } } @@ -111,6 +114,8 @@ abstract class BaseSubscribersCardViewModel( private suspend fun fetchData(siteId: Long) { try { loadDataInternal(siteId) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { AppLog.e( AppLog.T.STATS, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt index 12ed293cc42e..988c54d0b5e3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt @@ -12,11 +12,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button @@ -32,9 +30,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -49,8 +45,6 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.util.formatEmailStat -private const val LOAD_MORE_THRESHOLD = 5 - @AndroidEntryPoint class EmailsDetailActivity : BaseAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -84,34 +78,10 @@ private fun EmailsDetailScreen( ) { val items by viewModel.items.collectAsState() val isLoading by viewModel.isLoading.collectAsState() - val isLoadingMore by - viewModel.isLoadingMore.collectAsState() - val canLoadMore by - viewModel.canLoadMore.collectAsState() val hasError by viewModel.hasError.collectAsState() - val listState = rememberLazyListState() - LaunchedEffect(Unit) { - viewModel.loadInitialPage() - } - - val shouldLoadMore by remember { - derivedStateOf { - val lastVisible = - listState.layoutInfo.visibleItemsInfo - .lastOrNull()?.index ?: 0 - val totalItems = - listState.layoutInfo.totalItemsCount - canLoadMore && !isLoadingMore && - totalItems > 0 && - lastVisible >= totalItems - - LOAD_MORE_THRESHOLD - } - } - - LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore) viewModel.loadMore() + viewModel.loadData() } val title = stringResource( @@ -151,11 +121,10 @@ private fun EmailsDetailScreen( modifier = Modifier .fillMaxSize() .padding(contentPadding), - onRetry = { viewModel.loadInitialPage() } + onRetry = { viewModel.loadData() } ) } else { LazyColumn( - state = listState, modifier = Modifier .fillMaxSize() .padding(contentPadding) @@ -181,24 +150,6 @@ private fun EmailsDetailScreen( } } - if (isLoadingMore) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = - Alignment.Center - ) { - CircularProgressIndicator( - modifier = - Modifier.size(24.dp), - strokeWidth = 2.dp - ) - } - } - } - item { Spacer( modifier = Modifier.height(16.dp) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt index eba0bbbbdf51..dca979342895 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt @@ -7,15 +7,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.repository.EmailsStatsResult import org.wordpress.android.ui.newstats.repository.StatsRepository +import org.wordpress.android.util.AppLog import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException -internal const val EMAILS_DETAIL_PAGE_SIZE = 20 +private const val EMAILS_MAX_ITEMS = 25 @HiltViewModel class EmailsDetailViewModel @Inject constructor( @@ -33,99 +33,60 @@ class EmailsDetailViewModel @Inject constructor( val isLoading: StateFlow = _isLoading.asStateFlow() - private val _isLoadingMore = MutableStateFlow(false) - val isLoadingMore: StateFlow = - _isLoadingMore.asStateFlow() - - private val _canLoadMore = MutableStateFlow(true) - val canLoadMore: StateFlow = - _canLoadMore.asStateFlow() - private val _hasError = MutableStateFlow(false) val hasError: StateFlow = _hasError.asStateFlow() - private var currentQuantity = 0 - private val paginationMutex = Mutex() - - fun loadInitialPage() { + @Suppress("TooGenericExceptionCaught") + fun loadData() { viewModelScope.launch { - paginationMutex.withLock { - if (_items.value.isNotEmpty()) return@launch - currentQuantity = EMAILS_DETAIL_PAGE_SIZE - _isLoading.value = true - _hasError.value = false - _canLoadMore.value = true - fetchEmails(currentQuantity, isInitial = true) - _isLoading.value = false - } - } - } + if (_items.value.isNotEmpty()) return@launch + _isLoading.value = true + _hasError.value = false - // Note: The emails API only supports a "quantity" - // parameter (not page/offset), so each "load more" - // re-fetches all items with an increased quantity. - // This is a known API limitation. - fun loadMore() { - viewModelScope.launch { - paginationMutex.withLock { - if (!_canLoadMore.value || - _isLoadingMore.value - ) return@launch - _isLoadingMore.value = true - currentQuantity += EMAILS_DETAIL_PAGE_SIZE - fetchEmails( - currentQuantity, isInitial = false - ) - _isLoadingMore.value = false + val siteId = selectedSiteRepository + .getSelectedSite()?.siteId + val accessToken = accountStore.accessToken + if (siteId == null || + accessToken.isNullOrEmpty() + ) { + _hasError.value = true + _isLoading.value = false + return@launch } - } - } - - @Suppress("TooGenericExceptionCaught") - private suspend fun fetchEmails( - quantity: Int, - isInitial: Boolean - ) { - val siteId = selectedSiteRepository - .getSelectedSite()?.siteId ?: return - val accessToken = accountStore.accessToken - if (accessToken.isNullOrEmpty()) return - statsRepository.init(accessToken) + statsRepository.init(accessToken) - try { - val result = statsRepository.fetchEmailsSummary( - siteId = siteId, - quantity = quantity - ) - when (result) { - is EmailsStatsResult.Success -> { - val newItems = result.items.map { - EmailListItem( - title = it.title, - opens = it.opens, - clicks = it.clicks - ) + try { + val result = + statsRepository.fetchEmailsSummary( + siteId = siteId, + quantity = EMAILS_MAX_ITEMS + ) + when (result) { + is EmailsStatsResult.Success -> { + _items.value = result.items.map { + EmailListItem( + title = it.title, + opens = it.opens, + clicks = it.clicks + ) + } } - _items.value = newItems - _canLoadMore.value = - newItems.size == quantity - } - is EmailsStatsResult.Error -> { - if (isInitial) { + is EmailsStatsResult.Error -> { _hasError.value = true - _canLoadMore.value = false - } else { - currentQuantity -= EMAILS_DETAIL_PAGE_SIZE } } - } - } catch (_: Exception) { - if (isInitial) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Error fetching emails detail", + e + ) _hasError.value = true - _canLoadMore.value = false - } else { - currentQuantity -= EMAILS_DETAIL_PAGE_SIZE + } finally { + _isLoading.value = false } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt index 184338237d1a..a8c9aa4c1acf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt @@ -102,8 +102,8 @@ private fun SubscribersListDetailScreen( .lastOrNull()?.index ?: 0 val totalItems = listState.layoutInfo.totalItemsCount - canLoadMore && !isLoadingMore && - totalItems > 0 && + canLoadMore && !isLoading && + !isLoadingMore && totalItems > 0 && lastVisible >= totalItems - LOAD_MORE_THRESHOLD } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt index 26a0fb823eca..054a248c0a5e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt @@ -13,8 +13,10 @@ 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.SubscribersListResult +import org.wordpress.android.util.AppLog import org.wordpress.android.viewmodel.ContextProvider import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException internal const val SUBSCRIBERS_DETAIL_PAGE_SIZE = 20 @@ -39,7 +41,7 @@ class SubscribersListDetailViewModel @Inject constructor( val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() - private val _canLoadMore = MutableStateFlow(true) + private val _canLoadMore = MutableStateFlow(false) val canLoadMore: StateFlow = _canLoadMore.asStateFlow() @@ -121,22 +123,27 @@ class SubscribersListDetailViewModel @Inject constructor( newItems.size == SUBSCRIBERS_DETAIL_PAGE_SIZE } - is SubscribersListResult.Error -> { - if (isInitial) { - _hasError.value = true - _canLoadMore.value = false - } else { - currentPage-- - } - } - } - } catch (_: Exception) { - if (isInitial) { - _hasError.value = true - _canLoadMore.value = false - } else { - currentPage-- + is SubscribersListResult.Error -> + handleFetchError(isInitial) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e( + AppLog.T.STATS, + "Error fetching subscribers detail", + e + ) + handleFetchError(isInitial) + } + } + + private fun handleFetchError(isInitial: Boolean) { + if (isInitial) { + _hasError.value = true + _canLoadMore.value = false + } else { + currentPage-- } } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt index 035c07c1413a..2b762eddcd01 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt @@ -16,7 +16,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.ui.newstats.subscribers.SubscribersCardType -import org.wordpress.android.ui.newstats.subscribers.SubscribersCardsConfiguration import org.wordpress.android.ui.prefs.AppPrefsWrapper @ExperimentalCoroutinesApi diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt index a43ea2a4d060..b49b3133febb 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt @@ -7,7 +7,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -310,7 +309,7 @@ class BaseSubscribersCardViewModelTest : BaseUnitTest() { siteId: Long ) { if (shouldThrow) { - throw RuntimeException("Test error") + error("Test error") } loadCount++ markLoadedSuccessfully() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt index abb8cf4c98a9..7ac284b61ed3 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt @@ -8,7 +8,6 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any -import org.mockito.kotlin.eq import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -24,7 +23,8 @@ import org.wordpress.android.ui.newstats.repository.StatsRepository @RunWith(MockitoJUnitRunner.Silent::class) class EmailsDetailViewModelTest : BaseUnitTest() { @Mock - private lateinit var selectedSiteRepository: SelectedSiteRepository + private lateinit var selectedSiteRepository: + SelectedSiteRepository @Mock private lateinit var accountStore: AccountStore @@ -54,180 +54,57 @@ class EmailsDetailViewModelTest : BaseUnitTest() { } @Test - fun `when loadInitialPage succeeds, then items are populated`() = + fun `when loadData succeeds, then items are populated`() = test { whenever( - statsRepository.fetchEmailsSummary(any(), any()) + statsRepository.fetchEmailsSummary( + any(), any() + ) ).thenReturn( EmailsStatsResult.Success(createItems(3)) ) - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() assertThat(viewModel.items.value).hasSize(3) assertThat(viewModel.isLoading.value).isFalse() + assertThat(viewModel.hasError.value).isFalse() } @Test - fun `when loadInitialPage returns full page, then canLoadMore is true`() = - test { - whenever( - statsRepository.fetchEmailsSummary(any(), any()) - ).thenReturn( - EmailsStatsResult.Success( - createItems(PAGE_SIZE) - ) - ) - - viewModel.loadInitialPage() - advanceUntilIdle() - - assertThat(viewModel.canLoadMore.value).isTrue() - } - - @Test - fun `when loadInitialPage returns fewer than page size, then canLoadMore is false`() = - test { - whenever( - statsRepository.fetchEmailsSummary(any(), any()) - ).thenReturn( - EmailsStatsResult.Success(createItems(5)) - ) - - viewModel.loadInitialPage() - advanceUntilIdle() - - assertThat(viewModel.canLoadMore.value).isFalse() - } - - @Test - fun `when loadMore succeeds, then items are replaced with full set`() = + fun `when loadData errors, then hasError is true`() = test { whenever( statsRepository.fetchEmailsSummary( - any(), eq(PAGE_SIZE) - ) - ).thenReturn( - EmailsStatsResult.Success( - createItems(PAGE_SIZE) - ) - ) - whenever( - statsRepository.fetchEmailsSummary( - any(), eq(PAGE_SIZE * 2) - ) - ).thenReturn( - EmailsStatsResult.Success( - createItems(PAGE_SIZE * 2) - ) - ) - - viewModel.loadInitialPage() - advanceUntilIdle() - - viewModel.loadMore() - advanceUntilIdle() - - assertThat(viewModel.items.value) - .hasSize(PAGE_SIZE * 2) - assertThat(viewModel.isLoadingMore.value).isFalse() - } - - @Test - fun `when loadMore returns fewer than quantity, then canLoadMore becomes false`() = - test { - whenever( - statsRepository.fetchEmailsSummary( - any(), eq(PAGE_SIZE) - ) - ).thenReturn( - EmailsStatsResult.Success( - createItems(PAGE_SIZE) - ) - ) - whenever( - statsRepository.fetchEmailsSummary( - any(), eq(PAGE_SIZE * 2) + any(), any() ) - ).thenReturn( - EmailsStatsResult.Success( - createItems(PAGE_SIZE + 5) - ) - ) - - viewModel.loadInitialPage() - advanceUntilIdle() - - viewModel.loadMore() - advanceUntilIdle() - - assertThat(viewModel.canLoadMore.value).isFalse() - } - - @Test - fun `when loadInitialPage errors, then canLoadMore is false`() = - test { - whenever( - statsRepository.fetchEmailsSummary(any(), any()) ).thenReturn( EmailsStatsResult.Error(messageResId = 0) ) - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() - assertThat(viewModel.canLoadMore.value).isFalse() assertThat(viewModel.hasError.value).isTrue() } @Test - fun `when loadMore errors, then quantity is reverted for retry`() = + fun `when loadData called twice, then only loads once`() = test { whenever( statsRepository.fetchEmailsSummary( - any(), eq(PAGE_SIZE) - ) - ).thenReturn( - EmailsStatsResult.Success( - createItems(PAGE_SIZE) - ) - ) - whenever( - statsRepository.fetchEmailsSummary( - any(), eq(PAGE_SIZE * 2) + any(), any() ) - ).thenReturn( - EmailsStatsResult.Error(messageResId = 0) - ) - - viewModel.loadInitialPage() - advanceUntilIdle() - - viewModel.loadMore() - advanceUntilIdle() - - // Items unchanged from first load - assertThat(viewModel.items.value) - .hasSize(PAGE_SIZE) - // canLoadMore still true so retry is possible - assertThat(viewModel.canLoadMore.value).isTrue() - } - - @Test - fun `when loadInitialPage called twice, then only loads once`() = - test { - whenever( - statsRepository.fetchEmailsSummary(any(), any()) ).thenReturn( EmailsStatsResult.Success(createItems(3)) ) - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() verify(statsRepository, times(1)) @@ -235,26 +112,29 @@ class EmailsDetailViewModelTest : BaseUnitTest() { } @Test - fun `when no site selected, then items remain empty`() = + fun `when no site selected, then hasError is true`() = test { - whenever(selectedSiteRepository.getSelectedSite()) - .thenReturn(null) + whenever( + selectedSiteRepository.getSelectedSite() + ).thenReturn(null) - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() + assertThat(viewModel.hasError.value).isTrue() } @Test - fun `when access token is empty, then items remain empty`() = + fun `when access token is empty, then hasError is true`() = test { whenever(accountStore.accessToken).thenReturn("") - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() + assertThat(viewModel.hasError.value).isTrue() } @Test @@ -268,10 +148,14 @@ class EmailsDetailViewModelTest : BaseUnitTest() { ) ) whenever( - statsRepository.fetchEmailsSummary(any(), any()) - ).thenReturn(EmailsStatsResult.Success(items)) + statsRepository.fetchEmailsSummary( + any(), any() + ) + ).thenReturn( + EmailsStatsResult.Success(items) + ) - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() val item = viewModel.items.value[0] @@ -282,13 +166,17 @@ class EmailsDetailViewModelTest : BaseUnitTest() { } @Test - fun `when exception thrown, then items remain empty and hasError is true`() = + fun `when exception thrown, then hasError is true`() = test { whenever( - statsRepository.fetchEmailsSummary(any(), any()) - ).thenThrow(RuntimeException("Test exception")) + statsRepository.fetchEmailsSummary( + any(), any() + ) + ).thenThrow( + RuntimeException("Test exception") + ) - viewModel.loadInitialPage() + viewModel.loadData() advanceUntilIdle() assertThat(viewModel.items.value).isEmpty() @@ -306,8 +194,7 @@ class EmailsDetailViewModelTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L - private const val TEST_ACCESS_TOKEN = "test_access_token" - private const val PAGE_SIZE = - EMAILS_DETAIL_PAGE_SIZE + private const val TEST_ACCESS_TOKEN = + "test_access_token" } } From 9e19b42804b41b45e9b01ad189c9c79b3e85f162 Mon Sep 17 00:00:00 2001 From: adalpari Date: Tue, 3 Mar 2026 16:27:04 +0100 Subject: [PATCH 19/21] Simplify subscribers tab: extract shared composable, consolidate helpers - Extract NoConnectionContent into shared composable used by both NewStatsActivity and SubscribersTabContent - Consolidate 4 card movement methods in SubscribersCardsConfigurationRepository via moveCard() helper - Consolidate 6 card action methods in SubscribersTabViewModel via cardAction() helper - Extract CardActions data class in SubscribersTabContent to reduce callback repetition Co-Authored-By: Claude Opus 4.6 --- .../android/ui/newstats/NewStatsActivity.kt | 55 +--- .../components/NoConnectionContent.kt | 86 +++++++ ...SubscribersCardsConfigurationRepository.kt | 67 ++--- .../subscribers/SubscribersTabContent.kt | 236 +++++------------- .../subscribers/SubscribersTabViewModel.kt | 61 ++--- 5 files changed, 191 insertions(+), 314 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/components/NoConnectionContent.kt 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 0195c3ba6242..e983787b8ba9 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 @@ -4,22 +4,18 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.clickable import androidx.compose.material.icons.Icons @@ -27,7 +23,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -55,7 +50,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource @@ -72,6 +66,7 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.components.AddStatsCardBottomSheet import org.wordpress.android.ui.newstats.components.CardPosition +import org.wordpress.android.ui.newstats.components.NoConnectionContent import org.wordpress.android.ui.newstats.locations.LocationsCard import org.wordpress.android.ui.newstats.locations.LocationsDetailActivity import org.wordpress.android.ui.newstats.locations.LocationsViewModel @@ -878,54 +873,6 @@ private fun List.dispatchToVisibleCards( if (StatsCardType.DEVICES in this) onDevices() } -@Composable -private fun NoConnectionContent( - onRetry: () -> Unit -) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_wifi_off_24px), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .padding(12.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.no_connection_error_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.no_connection_error_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = onRetry) { - Text(stringResource(R.string.retry)) - } - } - } -} @Composable private fun PlaceholderTabContent(tab: StatsTab) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/NoConnectionContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/NoConnectionContent.kt new file mode 100644 index 000000000000..8345efdc7e95 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/NoConnectionContent.kt @@ -0,0 +1,86 @@ +package org.wordpress.android.ui.newstats.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.wordpress.android.R + +@Composable +fun NoConnectionContent(onRetry: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 60.dp), + horizontalAlignment = + Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource( + R.drawable.ic_wifi_off_24px + ), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .background( + color = MaterialTheme + .colorScheme.surfaceVariant, + shape = CircleShape + ) + .padding(12.dp), + tint = MaterialTheme + .colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource( + R.string.no_connection_error_title + ), + style = MaterialTheme + .typography.titleMedium, + color = MaterialTheme + .colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource( + R.string + .no_connection_error_description + ), + style = MaterialTheme + .typography.bodyMedium, + color = MaterialTheme + .colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt index a6666cf24685..674823957381 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt @@ -98,71 +98,48 @@ class SubscribersCardsConfigurationRepository @Inject constructor( suspend fun moveCardUp( siteId: Long, cardType: SubscribersCardType - ): Unit = withContext(ioDispatcher) { - mutex.withLock { - val current = loadConfiguration(siteId) - val index = - current.visibleCards.indexOf(cardType) - if (index > 0) { - moveCardToIndex( - siteId, current, - cardType, index - 1 - ) - } - } + ): Unit = moveCard(siteId, cardType) { idx, _ -> + if (idx > 0) idx - 1 else null } suspend fun moveCardToTop( siteId: Long, cardType: SubscribersCardType - ): Unit = withContext(ioDispatcher) { - mutex.withLock { - val current = loadConfiguration(siteId) - val index = - current.visibleCards.indexOf(cardType) - if (index > 0) { - moveCardToIndex( - siteId, current, cardType, 0 - ) - } - } + ): Unit = moveCard(siteId, cardType) { idx, _ -> + if (idx > 0) 0 else null } suspend fun moveCardDown( siteId: Long, cardType: SubscribersCardType - ): Unit = withContext(ioDispatcher) { - mutex.withLock { - val current = loadConfiguration(siteId) - val index = - current.visibleCards.indexOf(cardType) - if (index >= 0 && - index < current.visibleCards.size - 1 - ) { - moveCardToIndex( - siteId, current, - cardType, index + 1 - ) - } - } + ): Unit = moveCard(siteId, cardType) { idx, last -> + if (idx < last) idx + 1 else null } suspend fun moveCardToBottom( siteId: Long, cardType: SubscribersCardType + ): Unit = moveCard(siteId, cardType) { idx, last -> + if (idx < last) last else null + } + + private suspend fun moveCard( + siteId: Long, + cardType: SubscribersCardType, + targetIndex: (index: Int, lastIndex: Int) -> Int? ): Unit = withContext(ioDispatcher) { mutex.withLock { val current = loadConfiguration(siteId) val index = current.visibleCards.indexOf(cardType) - if (index >= 0 && - index < current.visibleCards.size - 1 - ) { - moveCardToIndex( - siteId, current, cardType, - current.visibleCards.size - 1 - ) - } + if (index < 0) return@withLock + val newIndex = targetIndex( + index, + current.visibleCards.size - 1 + ) ?: return@withLock + moveCardToIndex( + siteId, current, cardType, newIndex + ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt index 39b125205064..a86c4396f61f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt @@ -1,22 +1,16 @@ package org.wordpress.android.ui.newstats.subscribers -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.background import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -36,7 +30,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -45,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import org.wordpress.android.R import org.wordpress.android.ui.newstats.components.CardPosition +import org.wordpress.android.ui.newstats.components.NoConnectionContent import org.wordpress.android.ui.newstats.subscribers.alltimestats.AllTimeSubscribersCard import org.wordpress.android.ui.newstats.subscribers.alltimestats.AllTimeSubscribersViewModel import org.wordpress.android.ui.newstats.subscribers.emails.EmailsCard @@ -283,6 +277,9 @@ fun SubscribersTabContent( cardType -> val cardPosition = cardPositions[index] + val cardActions = cardActions( + subscribersTabViewModel, cardType + ) when (cardType) { SubscribersCardType .ALL_TIME_SUBSCRIBERS -> @@ -292,33 +289,17 @@ fun SubscribersTabContent( allTimeViewModel .loadData() }, - onRemoveCard = { - subscribersTabViewModel - .removeCard(cardType) - }, + onRemoveCard = + cardActions.onRemove, cardPosition = cardPosition, - onMoveUp = { - subscribersTabViewModel - .moveCardUp(cardType) - }, - onMoveToTop = { - subscribersTabViewModel - .moveCardToTop( - cardType - ) - }, - onMoveDown = { - subscribersTabViewModel - .moveCardDown( - cardType - ) - }, - onMoveToBottom = { - subscribersTabViewModel - .moveCardToBottom( - cardType - ) - } + onMoveUp = + cardActions.onMoveUp, + onMoveToTop = + cardActions.onMoveToTop, + onMoveDown = + cardActions.onMoveDown, + onMoveToBottom = + cardActions.onMoveToBottom ) SubscribersCardType @@ -334,33 +315,17 @@ fun SubscribersTabContent( onRetry = { graphViewModel.loadData() }, - onRemoveCard = { - subscribersTabViewModel - .removeCard(cardType) - }, + onRemoveCard = + cardActions.onRemove, cardPosition = cardPosition, - onMoveUp = { - subscribersTabViewModel - .moveCardUp(cardType) - }, - onMoveToTop = { - subscribersTabViewModel - .moveCardToTop( - cardType - ) - }, - onMoveDown = { - subscribersTabViewModel - .moveCardDown( - cardType - ) - }, - onMoveToBottom = { - subscribersTabViewModel - .moveCardToBottom( - cardType - ) - } + onMoveUp = + cardActions.onMoveUp, + onMoveToTop = + cardActions.onMoveToTop, + onMoveDown = + cardActions.onMoveDown, + onMoveToBottom = + cardActions.onMoveToBottom ) SubscribersCardType @@ -376,33 +341,17 @@ fun SubscribersTabContent( subscribersListViewModel .loadData() }, - onRemoveCard = { - subscribersTabViewModel - .removeCard(cardType) - }, + onRemoveCard = + cardActions.onRemove, cardPosition = cardPosition, - onMoveUp = { - subscribersTabViewModel - .moveCardUp(cardType) - }, - onMoveToTop = { - subscribersTabViewModel - .moveCardToTop( - cardType - ) - }, - onMoveDown = { - subscribersTabViewModel - .moveCardDown( - cardType - ) - }, - onMoveToBottom = { - subscribersTabViewModel - .moveCardToBottom( - cardType - ) - } + onMoveUp = + cardActions.onMoveUp, + onMoveToTop = + cardActions.onMoveToTop, + onMoveDown = + cardActions.onMoveDown, + onMoveToBottom = + cardActions.onMoveToBottom ) SubscribersCardType.EMAILS -> @@ -416,33 +365,17 @@ fun SubscribersTabContent( emailsViewModel .loadData() }, - onRemoveCard = { - subscribersTabViewModel - .removeCard(cardType) - }, + onRemoveCard = + cardActions.onRemove, cardPosition = cardPosition, - onMoveUp = { - subscribersTabViewModel - .moveCardUp(cardType) - }, - onMoveToTop = { - subscribersTabViewModel - .moveCardToTop( - cardType - ) - }, - onMoveDown = { - subscribersTabViewModel - .moveCardDown( - cardType - ) - }, - onMoveToBottom = { - subscribersTabViewModel - .moveCardToBottom( - cardType - ) - } + onMoveUp = + cardActions.onMoveUp, + onMoveToTop = + cardActions.onMoveToTop, + onMoveDown = + cardActions.onMoveDown, + onMoveToBottom = + cardActions.onMoveToBottom ) } } @@ -470,6 +403,27 @@ fun SubscribersTabContent( } } +private data class CardActions( + val onRemove: () -> Unit, + val onMoveUp: () -> Unit, + val onMoveToTop: () -> Unit, + val onMoveDown: () -> Unit, + val onMoveToBottom: () -> Unit +) + +private fun cardActions( + viewModel: SubscribersTabViewModel, + cardType: SubscribersCardType +) = CardActions( + onRemove = { viewModel.removeCard(cardType) }, + onMoveUp = { viewModel.moveCardUp(cardType) }, + onMoveToTop = { viewModel.moveCardToTop(cardType) }, + onMoveDown = { viewModel.moveCardDown(cardType) }, + onMoveToBottom = { + viewModel.moveCardToBottom(cardType) + } +) + private fun List .dispatchToVisibleCards( onAllTimeStats: () -> Unit, @@ -487,63 +441,3 @@ private fun List onEmails() } -@Composable -private fun NoConnectionContent(onRetry: () -> Unit) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 60.dp), - horizontalAlignment = - Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource( - R.drawable.ic_wifi_off_24px - ), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .background( - color = MaterialTheme - .colorScheme.surfaceVariant, - shape = CircleShape - ) - .padding(12.dp), - tint = MaterialTheme - .colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource( - R.string.no_connection_error_title - ), - style = MaterialTheme - .typography.titleMedium, - color = MaterialTheme - .colorScheme.onSurface, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource( - R.string - .no_connection_error_description - ), - style = MaterialTheme - .typography.bodyMedium, - color = MaterialTheme - .colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = onRetry) { - Text(stringResource(R.string.retry)) - } - } - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt index 1ce664d6cadc..5115aae40fe4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt @@ -97,60 +97,33 @@ class SubscribersTabViewModel @Inject constructor( } } - fun removeCard(cardType: SubscribersCardType) { - val currentSiteId = siteId - viewModelScope.launch { - cardConfigurationRepository.removeCard( - currentSiteId, cardType - ) - } - } + fun removeCard(cardType: SubscribersCardType) = + cardAction { removeCard(it, cardType) } - fun addCard(cardType: SubscribersCardType) { - val currentSiteId = siteId - viewModelScope.launch { - cardConfigurationRepository.addCard( - currentSiteId, cardType - ) - } - } + fun addCard(cardType: SubscribersCardType) = + cardAction { addCard(it, cardType) } - fun moveCardUp(cardType: SubscribersCardType) { - val currentSiteId = siteId - viewModelScope.launch { - cardConfigurationRepository.moveCardUp( - currentSiteId, cardType - ) - } - } + fun moveCardUp(cardType: SubscribersCardType) = + cardAction { moveCardUp(it, cardType) } - fun moveCardToTop(cardType: SubscribersCardType) { - val currentSiteId = siteId - viewModelScope.launch { - cardConfigurationRepository.moveCardToTop( - currentSiteId, cardType - ) - } - } + fun moveCardToTop(cardType: SubscribersCardType) = + cardAction { moveCardToTop(it, cardType) } - fun moveCardDown(cardType: SubscribersCardType) { - val currentSiteId = siteId - viewModelScope.launch { - cardConfigurationRepository.moveCardDown( - currentSiteId, cardType - ) - } - } + fun moveCardDown(cardType: SubscribersCardType) = + cardAction { moveCardDown(it, cardType) } fun moveCardToBottom( cardType: SubscribersCardType + ) = cardAction { moveCardToBottom(it, cardType) } + + private fun cardAction( + action: suspend SubscribersCardsConfigurationRepository.(Long) -> Unit ) { val currentSiteId = siteId viewModelScope.launch { - cardConfigurationRepository - .moveCardToBottom( - currentSiteId, cardType - ) + cardConfigurationRepository.action( + currentSiteId + ) } } } From 2ae18c45bf251163fae0a94fcf75cb4f4f6d9d6a Mon Sep 17 00:00:00 2001 From: adalpari Date: Thu, 5 Mar 2026 16:00:16 +0100 Subject: [PATCH 20/21] Update wordpress-rs to 3a23a7389b and adapt SortOrder rename - Update wordpress-rs library to commit 3a23a7389b3931e6241986a168b73a95d99151e0 - Adapt StatsEmailsSummarySortOrder -> WpApiParamOrder rename Co-Authored-By: Claude Opus 4.6 --- .../android/ui/newstats/datasource/StatsDataSourceImpl.kt | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 8d616597c084..88375b1e5c99 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 @@ -38,7 +38,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.StatsEmailsSummarySortOrder +import uniffi.wp_api.WpApiParamOrder import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import rs.wordpress.api.kotlin.fromLocale @@ -1123,7 +1123,7 @@ class StatsDataSourceImpl @Inject constructor( period = StatsEmailsSummaryPeriod.MONTH, quantity = quantity.toUInt(), sortField = StatsEmailsSummarySortField.OPENS, - sortOrder = StatsEmailsSummarySortOrder.DESC + sortOrder = WpApiParamOrder.DESC ) val result = getOrCreateClient().request { requestBuilder -> requestBuilder.statsEmailsSummary() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0595fb56cad8..d7a4bf1b3046 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '1200-b17d6e02dde5ea55773d527c1cb6ad2f889fc90e' +wordpress-rs = '1200-3a23a7389b3931e6241986a168b73a95d99151e0' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 040885b8c85a954a15c095d9402e1021e0930451 Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 16 Mar 2026 14:09:22 +0100 Subject: [PATCH 21/21] Updating worpdress-rs --- .../datasource/StatsDataSourceImpl.kt | 28 +++++++++---------- .../ui/subscribers/SubscriberDetailScreen.kt | 7 ++--- .../ui/subscribers/SubscribersViewModel.kt | 7 ++--- gradle/libs.versions.toml | 2 +- 4 files changed, 20 insertions(+), 24 deletions(-) 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 88375b1e5c99..a4422c31bf69 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 @@ -33,7 +33,7 @@ import uniffi.wp_api.StatsSubscribersParams import uniffi.wp_api.StatsSubscribersUnit import uniffi.wp_api.StatsSubscribersStatField import uniffi.wp_api.SubscribersByUserTypeParams -import uniffi.wp_api.SubscribersByUserTypeUserType +import uniffi.wp_api.WpComSubscriberType import uniffi.wp_api.SubscribersByUserTypeSortField import uniffi.wp_api.StatsEmailsSummaryParams import uniffi.wp_api.StatsEmailsSummaryPeriod @@ -1058,7 +1058,7 @@ class StatsDataSourceImpl @Inject constructor( page: Int ): SubscribersByUserTypeDataResult { val params = SubscribersByUserTypeParams( - userType = SubscribersByUserTypeUserType.WP_COM, + userType = WpComSubscriberType.WP_COM, perPage = perPage.toULong(), page = page.toULong(), sort = SubscribersByUserTypeSortField @@ -1090,19 +1090,17 @@ class StatsDataSourceImpl @Inject constructor( displayName = subscriber.displayName, subscribedSince = - subscriber.dateSubscribed - ?.let { - java.time.ZonedDateTime - .ofInstant( - it.toInstant(), - java.time.ZoneId - .systemDefault() - ).format( - java.time.format - .DateTimeFormatter - .ISO_LOCAL_DATE_TIME - ) - } ?: "" + java.time.ZonedDateTime + .ofInstant( + subscriber.dateSubscribed + .toInstant(), + java.time.ZoneId + .systemDefault() + ).format( + java.time.format + .DateTimeFormatter + .ISO_LOCAL_DATE_TIME + ) ) } ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt index 37cd4b73918d..f5285909c5a8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt @@ -231,10 +231,9 @@ fun NewsletterSubscriptionCard( DetailRow( label = stringResource(R.string.subscribers_date_label), - value = subscriber.dateSubscribed?.let { - SimpleDateFormatWrapper().getDateInstance() - .format(it) - } ?: "" + value = SimpleDateFormatWrapper() + .getDateInstance() + .format(subscriber.dateSubscribed) ) subscriber.subscriptionStatus?.let { status -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt index f5d0490d78b3..5471a36a88db 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt @@ -207,10 +207,9 @@ class SubscribersViewModel @Inject constructor( weight = .6f, ), DataViewItemField( - value = subscriber.dateSubscribed?.let { - dateFormatWrapper.getDateInstance() - .format(it) - } ?: "", + value = dateFormatWrapper + .getDateInstance() + .format(subscriber.dateSubscribed), valueType = DataViewFieldType.DATE, weight = .4f, ), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 793d77388301..d4a7e4070f6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-4fac42b1a262e93adcbc0cd0353aafa0369671d2' +wordpress-rs = 'trunk-502e9561f2a68294f0065867bab9214cc9a6b78c' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3'