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" + } +}