diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml
index d95b8f73e18e..fe2fb57f9661 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" />
+
+
+
+
+ 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
- }
- )
}
}
)
@@ -245,6 +257,7 @@ private fun NewStatsScreen(
private fun StatsTabContent(tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel) {
when (tab) {
StatsTab.TRAFFIC -> TrafficTabContent(viewsStatsViewModel = viewsStatsViewModel)
+ StatsTab.SUBSCRIBERS -> SubscribersTabContent()
else -> PlaceholderTabContent(tab)
}
}
@@ -860,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/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt
index 80919743050d..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
@@ -207,6 +207,48 @@ 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
+ * @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
+
+ /**
+ * Fetches a list of subscribers by user type.
+ *
+ * @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,
+ page: Int = 1
+ ): 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 +571,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..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
@@ -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.WpComSubscriberType
+import uniffi.wp_api.SubscribersByUserTypeSortField
+import uniffi.wp_api.StatsEmailsSummaryParams
+import uniffi.wp_api.StatsEmailsSummaryPeriod
+import uniffi.wp_api.StatsEmailsSummarySortField
+import uniffi.wp_api.WpApiParamOrder
import org.wordpress.android.util.AppLog
import org.wordpress.android.util.AppLog.T
import rs.wordpress.api.kotlin.fromLocale
@@ -54,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" }
@@ -983,6 +994,172 @@ 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 = subscribersUnit,
+ quantity = quantity.toUInt(),
+ date = date,
+ 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,
+ page: Int
+ ): SubscribersByUserTypeDataResult {
+ val params = SubscribersByUserTypeParams(
+ userType = WpComSubscriberType.WP_COM,
+ perPage = perPage.toULong(),
+ page = page.toULong(),
+ 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 =
+ java.time.ZonedDateTime
+ .ofInstant(
+ subscriber.dateSubscribed
+ .toInstant(),
+ java.time.ZoneId
+ .systemDefault()
+ ).format(
+ java.time.format
+ .DateTimeFormatter
+ .ISO_LOCAL_DATE_TIME
+ )
+ )
+ }
+ )
+ }
+ 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 = WpApiParamOrder.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..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
@@ -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
@@ -19,6 +20,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
@@ -32,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
@@ -66,6 +71,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 +1326,232 @@ 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 results = fetchAllTimeResults(siteId)
+
+ val firstError = results.filterIsInstance<
+ StatsSubscribersDataResult.Error>().firstOrNull()
+ if (firstError != null) {
+ return@withContext SubscribersAllTimeResult.Error(
+ messageResId =
+ firstError.errorType.messageResId,
+ isAuthError = firstError.errorType ==
+ StatsErrorType.AUTH_ERROR
+ )
+ }
+
+ val current = results[0]
+ val d30 = results[1]
+ val d60 = results[2]
+ val d90 = results[3]
+ SubscribersAllTimeResult.Success(
+ 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 0L
+ return data.subscribersData
+ .firstOrNull()?.count ?: 0L
+ }
+
+ /**
+ * 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) {
+ AppLog.e(
+ AppLog.T.STATS,
+ "Error fetching subscribers graph",
+ e
+ )
+ SubscribersGraphResult.Error(
+ messageResId = R.string.stats_error_api
+ )
+ }
+ }
+
+ /**
+ * 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) {
+ 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) {
+ 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
+ )
+ }
+ }
}
/**
@@ -1702,3 +1934,83 @@ 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
+)
+
+/**
+ * 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/repository/SubscribersCardsConfigurationRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt
new file mode 100644
index 000000000000..674823957381
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepository.kt
@@ -0,0 +1,221 @@
+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.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
+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 mutex = Mutex()
+
+ private val _configurationFlow = MutableStateFlow<
+ Pair?>(null)
+ val configurationFlow: StateFlow<
+ Pair?> =
+ _configurationFlow.asStateFlow()
+
+ suspend fun getConfiguration(
+ siteId: Long
+ ): SubscribersCardsConfiguration =
+ withContext(ioDispatcher) {
+ mutex.withLock {
+ loadConfiguration(siteId)
+ }
+ }
+
+ private 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) {
+ 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) {
+ 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 = moveCard(siteId, cardType) { idx, _ ->
+ if (idx > 0) idx - 1 else null
+ }
+
+ suspend fun moveCardToTop(
+ siteId: Long,
+ cardType: SubscribersCardType
+ ): Unit = moveCard(siteId, cardType) { idx, _ ->
+ if (idx > 0) 0 else null
+ }
+
+ suspend fun moveCardDown(
+ siteId: Long,
+ cardType: SubscribersCardType
+ ): 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) return@withLock
+ val newIndex = targetIndex(
+ index,
+ current.visibleCards.size - 1
+ ) ?: return@withLock
+ moveCardToIndex(
+ siteId, current, cardType, newIndex
+ )
+ }
+ }
+
+ 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)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun isValidConfiguration(
+ config: SubscribersCardsConfiguration
+ ): Boolean {
+ return try {
+ config.visibleCards.all {
+ it in SubscribersCardType.entries
+ }
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ 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/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/BaseSubscribersCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt
new file mode 100644
index 000000000000..cd6dd053c1e7
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModel.kt
@@ -0,0 +1,149 @@
+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
+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.util.AppLog
+import org.wordpress.android.viewmodel.ResourceProvider
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.coroutines.cancellation.CancellationException
+
+@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)
+ 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
+ )
+
+ 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)
+ resetLoadedSuccessfully()
+ isLoading.set(true)
+ loadJob?.cancel()
+ loadJob = viewModelScope.launch {
+ try {
+ _isRefreshing.value = true
+ fetchData(site.siteId)
+ } finally {
+ _isRefreshing.value = false
+ isLoading.set(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)
+
+ loadJob?.cancel()
+ loadJob = viewModelScope.launch {
+ try {
+ fetchData(site.siteId)
+ } finally {
+ isLoading.set(false)
+ }
+ }
+ }
+
+ private suspend fun fetchData(siteId: Long) {
+ try {
+ loadDataInternal(siteId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ AppLog.e(
+ AppLog.T.STATS,
+ "Error loading stats data",
+ e
+ )
+ updateState(
+ errorState(
+ 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/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/SubscribersTabContent.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt
new file mode 100644
index 000000000000..a86c4396f61f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabContent.kt
@@ -0,0 +1,443 @@
+package org.wordpress.android.ui.newstats.subscribers
+
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+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.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.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.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
+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 graphUiState by
+ graphViewModel.uiState.collectAsState()
+ val graphSelectedTab by
+ graphViewModel.selectedTab.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()
+
+ var previousVisibleCards by remember {
+ mutableStateOf?>(null)
+ }
+
+ LaunchedEffect(cardsToLoad) {
+ cardsToLoad.dispatchToVisibleCards(
+ onAllTimeStats = {
+ allTimeViewModel.loadDataIfNeeded()
+ },
+ onGraph = {
+ graphViewModel.loadDataIfNeeded()
+ },
+ onSubscribersList = {
+ subscribersListViewModel
+ .loadDataIfNeeded()
+ },
+ onEmails = {
+ emailsViewModel.loadDataIfNeeded()
+ }
+ )
+ }
+
+ 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,
+ 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]
+ val cardActions = cardActions(
+ subscribersTabViewModel, cardType
+ )
+ when (cardType) {
+ SubscribersCardType
+ .ALL_TIME_SUBSCRIBERS ->
+ AllTimeSubscribersCard(
+ uiState = allTimeUiState,
+ onRetry = {
+ allTimeViewModel
+ .loadData()
+ },
+ onRemoveCard =
+ cardActions.onRemove,
+ cardPosition = cardPosition,
+ onMoveUp =
+ cardActions.onMoveUp,
+ onMoveToTop =
+ cardActions.onMoveToTop,
+ onMoveDown =
+ cardActions.onMoveDown,
+ onMoveToBottom =
+ cardActions.onMoveToBottom
+ )
+
+ SubscribersCardType
+ .SUBSCRIBERS_GRAPH ->
+ SubscribersGraphCard(
+ uiState = graphUiState,
+ selectedTab =
+ graphSelectedTab,
+ onTabSelected = {
+ graphViewModel
+ .onTabSelected(it)
+ },
+ onRetry = {
+ graphViewModel.loadData()
+ },
+ onRemoveCard =
+ cardActions.onRemove,
+ cardPosition = cardPosition,
+ onMoveUp =
+ cardActions.onMoveUp,
+ onMoveToTop =
+ cardActions.onMoveToTop,
+ onMoveDown =
+ cardActions.onMoveDown,
+ onMoveToBottom =
+ cardActions.onMoveToBottom
+ )
+
+ SubscribersCardType
+ .SUBSCRIBERS_LIST ->
+ SubscribersListCard(
+ uiState =
+ subscribersListUiState,
+ onShowAllClick = {
+ SubscribersListDetailActivity
+ .start(context)
+ },
+ onRetry = {
+ subscribersListViewModel
+ .loadData()
+ },
+ onRemoveCard =
+ cardActions.onRemove,
+ cardPosition = cardPosition,
+ onMoveUp =
+ cardActions.onMoveUp,
+ onMoveToTop =
+ cardActions.onMoveToTop,
+ onMoveDown =
+ cardActions.onMoveDown,
+ onMoveToBottom =
+ cardActions.onMoveToBottom
+ )
+
+ SubscribersCardType.EMAILS ->
+ EmailsCard(
+ uiState = emailsUiState,
+ onShowAllClick = {
+ EmailsDetailActivity
+ .start(context)
+ },
+ onRetry = {
+ emailsViewModel
+ .loadData()
+ },
+ onRemoveCard =
+ cardActions.onRemove,
+ cardPosition = cardPosition,
+ onMoveUp =
+ cardActions.onMoveUp,
+ onMoveToTop =
+ cardActions.onMoveToTop,
+ onMoveDown =
+ cardActions.onMoveDown,
+ onMoveToBottom =
+ cardActions.onMoveToBottom
+ )
+ }
+ }
+
+ // 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 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,
+ 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()
+}
+
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..5115aae40fe4
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModel.kt
@@ -0,0 +1,129 @@
+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 java.util.concurrent.atomic.AtomicBoolean
+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 isInitialLoad = AtomicBoolean(true)
+
+ 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()
+ if (isInitialLoad.compareAndSet(true, false)) {
+ _cardsToLoad.value = config.visibleCards
+ }
+ }
+
+ fun removeCard(cardType: SubscribersCardType) =
+ cardAction { removeCard(it, cardType) }
+
+ fun addCard(cardType: SubscribersCardType) =
+ cardAction { addCard(it, cardType) }
+
+ fun moveCardUp(cardType: SubscribersCardType) =
+ cardAction { moveCardUp(it, cardType) }
+
+ fun moveCardToTop(cardType: SubscribersCardType) =
+ cardAction { moveCardToTop(it, 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.action(
+ currentSiteId
+ )
+ }
+ }
+}
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..e13d06ebad5f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersCard.kt
@@ -0,0 +1,249 @@
+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
+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.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
+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 SubCardCornerRadius = 8.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(12.dp))
+ ShimmerBox(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clip(
+ RoundedCornerShape(SubCardCornerRadius)
+ )
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement =
+ Arrangement.spacedBy(8.dp)
+ ) {
+ repeat(3) {
+ ShimmerBox(
+ modifier = Modifier
+ .weight(1f)
+ .height(52.dp)
+ .clip(
+ RoundedCornerShape(
+ SubCardCornerRadius
+ )
+ )
+ )
+ }
+ }
+ }
+}
+
+@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(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.spacedBy(8.dp)
+ ) {
+ StatItem(
+ label = stringResource(
+ R.string.stats_subscribers_30_days_ago
+ ),
+ value = state.count30DaysAgo,
+ modifier = Modifier.weight(1f)
+ )
+ StatItem(
+ label = stringResource(
+ R.string.stats_subscribers_60_days_ago
+ ),
+ value = state.count60DaysAgo,
+ modifier = Modifier.weight(1f)
+ )
+ StatItem(
+ label = stringResource(
+ R.string.stats_subscribers_90_days_ago
+ ),
+ value = state.count90DaysAgo,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+}
+
+@Composable
+private fun HighlightedStatItem(
+ label: String,
+ value: Long
+) {
+ Column(
+ 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.headlineLarge,
+ fontWeight = FontWeight.Bold,
+ 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.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/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..e8d435510d41
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModel.kt
@@ -0,0 +1,60 @@
+package org.wordpress.android.ui.newstats.subscribers.alltimestats
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+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(
+ 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
+ )
+ )
+ }
+ 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
new file mode 100644
index 000000000000..4e40251d3c14
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCard.kt
@@ -0,0 +1,289 @@
+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.HorizontalDivider
+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.TextAlign
+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.formatEmailStat
+
+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 {
+ EmailColumnHeaders()
+ state.items.forEach { item ->
+ EmailItemRow(item = item)
+ }
+ 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,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.width(56.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = stringResource(
+ R.string.stats_emails_clicks_header
+ ),
+ style = MaterialTheme
+ .typography.labelMedium,
+ color = MaterialTheme
+ .colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.width(56.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ HorizontalDivider(
+ color = MaterialTheme
+ .colorScheme.outlineVariant
+ )
+}
+
+@Composable
+private fun EmailItemRow(item: EmailListItem) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.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)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = formatEmailStat(item.opens),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ 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 = formatEmailStat(item.clicks),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = if (item.clicks == 0L) {
+ MaterialTheme
+ .colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme
+ .colorScheme.onSurface
+ },
+ textAlign = TextAlign.Center,
+ modifier = Modifier.width(56.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..fc0d49427aa5
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModel.kt
@@ -0,0 +1,70 @@
+package org.wordpress.android.ui.newstats.subscribers.emails
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+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
+
+private const val CARD_MAX_ITEMS = 5
+
+@HiltViewModel
+class EmailsCardViewModel @Inject constructor(
+ selectedSiteRepository: SelectedSiteRepository,
+ accountStore: AccountStore,
+ statsRepository: StatsRepository,
+ resourceProvider: ResourceProvider
+) : BaseSubscribersCardViewModel(
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ resourceProvider,
+ EmailsCardUiState.Loading
+) {
+ override val loadingState = EmailsCardUiState.Loading
+
+ override fun errorState(
+ message: String,
+ isAuthError: Boolean
+ ) = EmailsCardUiState.Error(message, isAuthError)
+
+ 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
+ )
+ }
+ )
+ )
+ }
+ is EmailsStatsResult.Error -> {
+ updateState(
+ EmailsCardUiState.Error(
+ message = resourceProvider
+ .getString(
+ result.messageResId
+ ),
+ isAuthError = result.isAuthError
+ )
+ )
+ }
+ }
+ }
+}
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..988c54d0b5e3
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailActivity.kt
@@ -0,0 +1,292 @@
+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.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.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+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
+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.getValue
+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 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
+
+@AndroidEntryPoint
+class EmailsDetailActivity : BaseAppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ AppThemeM3 {
+ EmailsDetailScreen(
+ onBackPressed =
+ onBackPressedDispatcher::onBackPressed
+ )
+ }
+ }
+ }
+
+ companion object {
+ fun start(context: Context) {
+ val intent = Intent(
+ context,
+ EmailsDetailActivity::class.java
+ )
+ context.startActivity(intent)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun EmailsDetailScreen(
+ viewModel: EmailsDetailViewModel = viewModel(),
+ onBackPressed: () -> Unit
+) {
+ val items by viewModel.items.collectAsState()
+ val isLoading by viewModel.isLoading.collectAsState()
+ val hasError by viewModel.hasError.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.loadData()
+ }
+
+ 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 ->
+ if (isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else if (hasError) {
+ ErrorContent(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ onRetry = { viewModel.loadData() }
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(horizontal = 16.dp)
+ ) {
+ item {
+ Spacer(
+ modifier = Modifier.height(8.dp)
+ )
+ DetailEmailColumnHeaders()
+ 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() {
+ 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,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.width(56.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = stringResource(
+ R.string.stats_emails_clicks_header
+ ),
+ style = MaterialTheme
+ .typography.labelMedium,
+ color = MaterialTheme
+ .colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.width(56.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ HorizontalDivider(
+ color = MaterialTheme
+ .colorScheme.outlineVariant
+ )
+}
+
+@Composable
+private fun DetailEmailRow(item: EmailListItem) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.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)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = formatEmailStat(item.opens),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ 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 = formatEmailStat(item.clicks),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = if (item.clicks == 0L) {
+ MaterialTheme
+ .colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme
+ .colorScheme.onSurface
+ },
+ textAlign = TextAlign.Center,
+ modifier = Modifier.width(56.dp)
+ )
+ }
+}
+
+@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
new file mode 100644
index 000000000000..dca979342895
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModel.kt
@@ -0,0 +1,93 @@
+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.util.AppLog
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+
+private const val EMAILS_MAX_ITEMS = 25
+
+@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 _hasError = MutableStateFlow(false)
+ val hasError: StateFlow =
+ _hasError.asStateFlow()
+
+ @Suppress("TooGenericExceptionCaught")
+ fun loadData() {
+ viewModelScope.launch {
+ if (_items.value.isNotEmpty()) return@launch
+ _isLoading.value = true
+ _hasError.value = false
+
+ val siteId = selectedSiteRepository
+ .getSelectedSite()?.siteId
+ val accessToken = accountStore.accessToken
+ if (siteId == null ||
+ accessToken.isNullOrEmpty()
+ ) {
+ _hasError.value = true
+ _isLoading.value = false
+ return@launch
+ }
+ statsRepository.init(accessToken)
+
+ 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
+ )
+ }
+ }
+ is EmailsStatsResult.Error -> {
+ _hasError.value = true
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ AppLog.e(
+ AppLog.T.STATS,
+ "Error fetching emails detail",
+ e
+ )
+ _hasError.value = true
+ } finally {
+ _isLoading.value = false
+ }
+ }
+ }
+}
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..41391923d28b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphCard.kt
@@ -0,0 +1,364 @@
+package org.wordpress.android.ui.newstats.subscribers.subscribersgraph
+
+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.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 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,
+ onMoveUp: (() -> Unit)? = null,
+ onMoveToTop: (() -> Unit)? = null,
+ onMoveDown: (() -> Unit)? = null,
+ onMoveToBottom: (() -> Unit)? = null
+) {
+ StatsCardContainer(modifier = modifier) {
+ 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()
+ .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
+ )
+ }
+ )
+ }
+ }
+}
+
+@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 {
+ @Suppress("ReturnCount")
+ 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
new file mode 100644
index 000000000000..3460d0021859
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphUiState.kt
@@ -0,0 +1,34 @@
+package org.wordpress.android.ui.newstats.subscribers.subscribersgraph
+
+import androidx.annotation.StringRes
+import org.wordpress.android.R
+
+sealed class SubscribersGraphUiState {
+ data object Loading : SubscribersGraphUiState()
+ data class Loaded(
+ val dataPoints: List
+ ) : 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
new file mode 100644
index 000000000000..3bc8aaa4e032
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModel.kt
@@ -0,0 +1,126 @@
+package org.wordpress.android.ui.newstats.subscribers.subscribersgraph
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+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
+import java.util.Locale
+import javax.inject.Inject
+
+@HiltViewModel
+class SubscribersGraphViewModel @Inject constructor(
+ 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()
+
+ override val loadingState = SubscribersGraphUiState.Loading
+
+ override fun errorState(
+ message: String,
+ isAuthError: Boolean
+ ) = SubscribersGraphUiState.Error(message, isAuthError)
+
+ fun onTabSelected(tab: SubscribersGraphTab) {
+ if (tab == _selectedTab.value) return
+ _selectedTab.value = tab
+ resetLoadedSuccessfully()
+ loadData()
+ }
+
+ 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
+ ),
+ count = it.count
+ )
+ }
+ )
+ )
+ }
+ is SubscribersGraphResult.Error -> {
+ updateState(
+ SubscribersGraphUiState.Error(
+ message = resourceProvider
+ .getString(result.messageResId),
+ isAuthError = result.isAuthError
+ )
+ )
+ }
+ }
+ }
+
+ @Suppress("MagicNumber")
+ private fun formatLabel(
+ dateStr: String,
+ tab: SubscribersGraphTab
+ ): String {
+ return try {
+ val date = LocalDate.parse(dateStr)
+ when (tab) {
+ SubscribersGraphTab.DAYS,
+ 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/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..1cd8d86fb9d1
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListCard.kt
@@ -0,0 +1,310 @@
+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 = item.formattedDate,
+ style = MaterialTheme
+ .typography.bodySmall,
+ color = MaterialTheme
+ .colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Suppress("TooGenericExceptionCaught", "SwallowedException")
+internal fun formatSubscriberDate(
+ dateString: String,
+ resources: android.content.res.Resources
+): String {
+ return try {
+ val subscribed = parseSubscriberDate(dateString)
+ 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 {
+ totalDays < 1L -> resources.getString(
+ R.string.stats_subscriber_since_today
+ )
+ period.years < 1 -> resources
+ .getQuantityString(
+ R.plurals.stats_subscriber_days,
+ totalDays.toInt(), totalDays.toInt()
+ )
+ 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
+ )
+}
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..a8c9aa4c1acf
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailActivity.kt
@@ -0,0 +1,307 @@
+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.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.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
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.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
+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
+
+private const val LOAD_MORE_THRESHOLD = 5
+
+@AndroidEntryPoint
+class SubscribersListDetailActivity :
+ BaseAppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ AppThemeM3 {
+ SubscribersListDetailScreen(
+ onBackPressed =
+ onBackPressedDispatcher::onBackPressed
+ )
+ }
+ }
+ }
+
+ companion object {
+ fun start(context: Context) {
+ val intent = Intent(
+ context,
+ SubscribersListDetailActivity::class.java
+ )
+ context.startActivity(intent)
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SubscribersListDetailScreen(
+ 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 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 && !isLoading &&
+ !isLoadingMore && totalItems > 0 &&
+ lastVisible >= totalItems -
+ LOAD_MORE_THRESHOLD
+ }
+ }
+
+ LaunchedEffect(shouldLoadMore) {
+ if (shouldLoadMore) viewModel.loadMore()
+ }
+
+ 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 ->
+ if (isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else if (hasError) {
+ ErrorContent(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ onRetry = {
+ viewModel.loadInitialPage()
+ }
+ )
+ } 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(8.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)
+ )
+ }
+ }
+ }
+ }
+}
+
+@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 = item.formattedDate,
+ style = MaterialTheme
+ .typography.bodySmall,
+ color = MaterialTheme
+ .colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@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
new file mode 100644
index 000000000000..054a248c0a5e
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModel.kt
@@ -0,0 +1,149 @@
+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 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
+
+@HiltViewModel
+class SubscribersListDetailViewModel @Inject constructor(
+ private val selectedSiteRepository: SelectedSiteRepository,
+ private val accountStore: AccountStore,
+ private val statsRepository: StatsRepository,
+ private val contextProvider: ContextProvider
+) : 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(false)
+ 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() {
+ 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)
+ _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)
+
+ val resources =
+ contextProvider.getContext().resources
+ try {
+ val result = statsRepository.fetchSubscribersList(
+ siteId = siteId,
+ perPage = SUBSCRIBERS_DETAIL_PAGE_SIZE,
+ page = page
+ )
+ when (result) {
+ is SubscribersListResult.Success -> {
+ val newItems = result.subscribers.map {
+ SubscriberListItem(
+ displayName = it.displayName,
+ subscribedSince =
+ it.subscribedSince,
+ formattedDate =
+ formatSubscriberDate(
+ it.subscribedSince,
+ resources
+ )
+ )
+ }
+ if (isInitial) {
+ _items.value = newItems
+ } else {
+ _items.value =
+ _items.value + newItems
+ }
+ _canLoadMore.value =
+ newItems.size ==
+ SUBSCRIBERS_DETAIL_PAGE_SIZE
+ }
+ 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/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..958a809f8c4b
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListUiState.kt
@@ -0,0 +1,30 @@
+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,
+ val formattedDate: 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..247d9c3f72e8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModel.kt
@@ -0,0 +1,80 @@
+package org.wordpress.android.ui.newstats.subscribers.subscriberslist
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+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.ContextProvider
+import org.wordpress.android.viewmodel.ResourceProvider
+import javax.inject.Inject
+
+private const val CARD_MAX_ITEMS = 5
+
+@HiltViewModel
+class SubscribersListViewModel @Inject constructor(
+ selectedSiteRepository: SelectedSiteRepository,
+ accountStore: AccountStore,
+ statsRepository: StatsRepository,
+ resourceProvider: ResourceProvider,
+ private val contextProvider: ContextProvider
+) : BaseSubscribersCardViewModel(
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ resourceProvider,
+ SubscribersListUiState.Loading
+) {
+ override val loadingState = SubscribersListUiState.Loading
+
+ override fun errorState(
+ message: String,
+ isAuthError: Boolean
+ ) = SubscribersListUiState.Error(message, isAuthError)
+
+ override suspend fun loadDataInternal(siteId: Long) {
+ when (
+ val result = statsRepository
+ .fetchSubscribersList(
+ siteId, CARD_MAX_ITEMS
+ )
+ ) {
+ is SubscribersListResult.Success -> {
+ markLoadedSuccessfully()
+ val resources = contextProvider
+ .getContext().resources
+ updateState(
+ SubscribersListUiState.Loaded(
+ items = result.subscribers
+ .take(CARD_MAX_ITEMS)
+ .map {
+ SubscriberListItem(
+ displayName =
+ it.displayName,
+ subscribedSince =
+ it.subscribedSince,
+ formattedDate =
+ formatSubscriberDate(
+ it.subscribedSince,
+ resources
+ )
+ )
+ }
+ )
+ )
+ }
+ 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/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/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt
index efc7e44e4ffb..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,7 +231,9 @@ fun NewsletterSubscriptionCard(
DetailRow(
label = stringResource(R.string.subscribers_date_label),
- value = SimpleDateFormatWrapper().getDateInstance().format(subscriber.dateSubscribed)
+ 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 753db2c8e60e..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,7 +207,9 @@ class SubscribersViewModel @Inject constructor(
weight = .6f,
),
DataViewItemField(
- value = dateFormatWrapper.getDateInstance().format(subscriber.dateSubscribed),
+ value = dateFormatWrapper
+ .getDateInstance()
+ .format(subscriber.dateSubscribed),
valueType = DataViewFieldType.DATE,
weight = .4f,
),
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 67933f861c34..b3c306e9f692 100644
--- a/WordPress/src/main/res/values/strings.xml
+++ b/WordPress/src/main/res/values/strings.xml
@@ -1611,6 +1611,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
@@ -1656,6 +1657,27 @@
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
+ Days
+ Weeks
+ Months
+ Years
+ today
+ %1$s, %2$s
+
Remove Card
Move 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
new file mode 100644
index 000000000000..cafb5660df15
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositorySubscribersTest.kt
@@ -0,0 +1,397 @@
+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.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
+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(), anyOrNull(), anyOrNull()))
+ .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(), anyOrNull(), anyOrNull()))
+ .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(), anyOrNull(), anyOrNull()))
+ .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(), anyOrNull(), anyOrNull()))
+ .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(), 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 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(), 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(), 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(), 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 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`() =
+ 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..2b762eddcd01
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/SubscribersCardsConfigurationRepositoryTest.kt
@@ -0,0 +1,315 @@
+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.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 addCard is called, then json is saved to prefs`() = test {
+ whenever(appPrefsWrapper.getSubscribersCardsConfigurationJson(TEST_SITE_ID))
+ .thenReturn("""{"visibleCards":["ALL_TIME_SUBSCRIBERS"]}""")
+
+ repository.addCard(TEST_SITE_ID, SubscribersCardType.EMAILS)
+
+ 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("""{"visibleCards":[]}""")
+
+ 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
+ )
+ }
+
+ companion object {
+ private const val TEST_SITE_ID = 123L
+ }
+}
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..b49b3133febb
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/BaseSubscribersCardViewModelTest.kt
@@ -0,0 +1,325 @@
+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.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) {
+ error("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/SubscribersTabViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt
new file mode 100644
index 000000000000..4563c1998e03
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/SubscribersTabViewModelTest.kt
@@ -0,0 +1,344 @@
+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 not updated after initial load`() =
+ test {
+ initViewModel()
+ advanceUntilIdle()
+
+ val initialCardsToLoad = viewModel.cardsToLoad.value
+
+ 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
+ @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/alltimestats/AllTimeSubscribersViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt
new file mode 100644
index 000000000000..396571d4d89f
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/alltimestats/AllTimeSubscribersViewModelTest.kt
@@ -0,0 +1,289 @@
+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)
+ 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() {
+ 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 unknown error 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(UNKNOWN_ERROR)
+ }
+
+ @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)
+ }
+
+ @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,
+ 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"
+ 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
new file mode 100644
index 000000000000..abf4e68c231d
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsCardViewModelTest.kt
@@ -0,0 +1,282 @@
+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.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
+@RunWith(MockitoJUnitRunner.Silent::class)
+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)
+ 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() {
+ 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 unknown error 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(UNKNOWN_ERROR)
+ }
+
+ @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 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)
+ }
+
+ @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)
+ )
+
+ 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 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
new file mode 100644
index 000000000000..7ac284b61ed3
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/emails/EmailsDetailViewModelTest.kt
@@ -0,0 +1,200 @@
+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.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 loadData succeeds, then items are populated`() =
+ test {
+ whenever(
+ statsRepository.fetchEmailsSummary(
+ any(), any()
+ )
+ ).thenReturn(
+ EmailsStatsResult.Success(createItems(3))
+ )
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ assertThat(viewModel.items.value).hasSize(3)
+ assertThat(viewModel.isLoading.value).isFalse()
+ assertThat(viewModel.hasError.value).isFalse()
+ }
+
+ @Test
+ fun `when loadData errors, then hasError is true`() =
+ test {
+ whenever(
+ statsRepository.fetchEmailsSummary(
+ any(), any()
+ )
+ ).thenReturn(
+ EmailsStatsResult.Error(messageResId = 0)
+ )
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ assertThat(viewModel.items.value).isEmpty()
+ assertThat(viewModel.hasError.value).isTrue()
+ }
+
+ @Test
+ fun `when loadData called twice, then only loads once`() =
+ test {
+ whenever(
+ statsRepository.fetchEmailsSummary(
+ any(), any()
+ )
+ ).thenReturn(
+ EmailsStatsResult.Success(createItems(3))
+ )
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ verify(statsRepository, times(1))
+ .fetchEmailsSummary(any(), any())
+ }
+
+ @Test
+ fun `when no site selected, then hasError is true`() =
+ test {
+ whenever(
+ selectedSiteRepository.getSelectedSite()
+ ).thenReturn(null)
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ assertThat(viewModel.items.value).isEmpty()
+ assertThat(viewModel.hasError.value).isTrue()
+ }
+
+ @Test
+ fun `when access token is empty, then hasError is true`() =
+ test {
+ whenever(accountStore.accessToken).thenReturn("")
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ assertThat(viewModel.items.value).isEmpty()
+ assertThat(viewModel.hasError.value).isTrue()
+ }
+
+ @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.loadData()
+ 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 hasError is true`() =
+ test {
+ whenever(
+ statsRepository.fetchEmailsSummary(
+ any(), any()
+ )
+ ).thenThrow(
+ RuntimeException("Test exception")
+ )
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ assertThat(viewModel.items.value).isEmpty()
+ assertThat(viewModel.hasError.value).isTrue()
+ }
+
+ 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"
+ }
+}
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..f2cc1cdf1960
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscribersgraph/SubscribersGraphViewModelTest.kt
@@ -0,0 +1,578 @@
+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() {
+ @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)
+ 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() {
+ viewModel = SubscribersGraphViewModel(
+ 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(
+ SubscribersGraphUiState.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(
+ SubscribersGraphUiState.Error::class.java
+ )
+ }
+
+ @Test
+ fun `when data loads successfully, then loaded state has data points`() =
+ test {
+ whenever(
+ statsRepository.fetchSubscribersGraph(
+ any(), any(), any(), any()
+ )
+ ).thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ val state = viewModel.uiState.value
+ assertThat(state).isInstanceOf(
+ SubscribersGraphUiState.Loaded::class.java
+ )
+ val loaded =
+ state as SubscribersGraphUiState.Loaded
+ assertThat(loaded.dataPoints).hasSize(3)
+ }
+
+ @Test
+ 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
+ )
+ )
+
+ initViewModel()
+ advanceUntilIdle()
+
+ 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 exception is thrown, then error state has message`() =
+ test {
+ whenever(
+ statsRepository.fetchSubscribersGraph(
+ any(), any(), any(), any()
+ )
+ ).thenThrow(RuntimeException("Test error"))
+
+ 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 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 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 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 {
+ whenever(
+ statsRepository.fetchSubscribersGraph(
+ any(), any(), any(), any()
+ )
+ ).thenReturn(createSuccessResult())
+
+ initViewModel()
+ advanceUntilIdle()
+
+ verify(statsRepository)
+ .fetchSubscribersGraph(
+ eq(TEST_SITE_ID),
+ eq("day"),
+ eq(DAYS_QUANTITY),
+ any()
+ )
+ }
+
+ @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()
+ }
+
+ @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(
+ SubscribersGraphDataPoint(
+ "2026-02-25", TEST_COUNT_1
+ ),
+ SubscribersGraphDataPoint(
+ "2026-02-26", TEST_COUNT_2
+ ),
+ SubscribersGraphDataPoint(
+ "2026-02-27", TEST_COUNT_3
+ )
+ )
+ )
+
+ 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 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
+ private const val DAYS_QUANTITY = 30
+ private const val WEEKS_QUANTITY = 12
+ private const val MONTHS_QUANTITY = 6
+ private const val YEARS_QUANTITY = 3
+ }
+}
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..145ea5b7e637
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/FormatSubscriberDateTest.kt
@@ -0,0 +1,201 @@
+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 subscribed = LocalDate.now().minusYears(1)
+ val dateStr = subscribed.format(
+ DateTimeFormatter.ISO_LOCAL_DATE
+ )
+ val result = formatSubscriberDate(
+ 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(
+ 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(
+ dateStr, resources
+ )
+ assertThat(result).isEqualTo("2 years")
+ }
+
+ @Test
+ 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(
+ dateStr, resources
+ )
+ assertThat(result).isEqualTo(
+ "${period.years} years, $remaining 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/SubscribersListDetailViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt
new file mode 100644
index 000000000000..9ba0cd0b842b
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListDetailViewModelTest.kt
@@ -0,0 +1,334 @@
+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
+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
+import org.wordpress.android.viewmodel.ContextProvider
+
+@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
+
+ @Mock
+ private lateinit var contextProvider: ContextProvider
+
+ @Mock
+ private lateinit var context: Context
+
+ @Mock
+ private lateinit var resources: Resources
+
+ 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)
+ whenever(contextProvider.getContext())
+ .thenReturn(context)
+ whenever(context.resources).thenReturn(resources)
+ viewModel = SubscribersListDetailViewModel(
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ contextProvider
+ )
+ }
+
+ @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()
+ assertThat(viewModel.hasError.value).isTrue()
+ }
+
+ @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 empty and hasError is true`() =
+ test {
+ whenever(
+ statsRepository.fetchSubscribersList(
+ any(), any(), any()
+ )
+ ).thenThrow(RuntimeException("Test exception"))
+
+ viewModel.loadInitialPage()
+ advanceUntilIdle()
+
+ assertThat(viewModel.items.value).isEmpty()
+ assertThat(viewModel.hasError.value).isTrue()
+ }
+
+ 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 =
+ 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
new file mode 100644
index 000000000000..699aa696ee23
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/subscribers/subscriberslist/SubscribersListViewModelTest.kt
@@ -0,0 +1,301 @@
+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.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.ContextProvider
+import org.wordpress.android.viewmodel.ResourceProvider
+
+@ExperimentalCoroutinesApi
+@RunWith(MockitoJUnitRunner.Silent::class)
+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
+
+ @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 {
+ 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)
+ 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")
+ 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() {
+ viewModel = SubscribersListViewModel(
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ resourceProvider,
+ contextProvider
+ )
+ 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(), 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(), 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(), 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(), 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 unknown error message`() = test {
+ whenever(statsRepository.fetchSubscribersList(any(), 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(UNKNOWN_ERROR)
+ }
+
+ @Test
+ fun `when loadDataIfNeeded called multiple times, then data is only loaded once`() = test {
+ whenever(statsRepository.fetchSubscribersList(any(), any(), any()))
+ .thenReturn(SubscribersListResult.Success(createTestItems()))
+
+ viewModel = SubscribersListViewModel(
+ selectedSiteRepository,
+ accountStore,
+ statsRepository,
+ resourceProvider,
+ contextProvider
+ )
+ viewModel.loadDataIfNeeded()
+ advanceUntilIdle()
+
+ viewModel.loadDataIfNeeded()
+ advanceUntilIdle()
+
+ 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(), any()))
+ .thenReturn(SubscribersListResult.Success(createTestItems()))
+
+ initViewModel()
+ advanceUntilIdle()
+
+ viewModel.loadData()
+ advanceUntilIdle()
+
+ 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(), any()))
+ .thenReturn(SubscribersListResult.Success(createTestItems()))
+
+ initViewModel()
+ advanceUntilIdle()
+
+ assertThat(viewModel.isRefreshing.value).isFalse()
+
+ viewModel.refresh()
+ advanceUntilIdle()
+
+ assertThat(viewModel.isRefreshing.value).isFalse()
+ }
+
+ @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(), 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")
+ }
+
+ @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")
+ )
+
+ 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 UNKNOWN_ERROR = "Unknown error"
+ }
+}