diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt index 214e62d78a7..94ced538fcc 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/model/DashboardWidget.kt @@ -28,7 +28,8 @@ data class DashboardWidget( ONBOARDING(R.string.my_store_widget_onboarding_title, "store_setup"), STATS(R.string.my_store_widget_stats_title, "performance"), POPULAR_PRODUCTS(R.string.my_store_widget_top_products_title, "top_performers"), - BLAZE(R.string.my_store_widget_blaze_title, "blaze") + BLAZE(R.string.my_store_widget_blaze_title, "blaze"), + REVIEWS(R.string.my_store_widget_reviews_title, "reviews"), } sealed interface Status : Parcelable { diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt index ea8f7461694..25975bf4e3b 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/DashboardContainer.kt @@ -35,6 +35,7 @@ import com.woocommerce.android.ui.compose.component.WCOutlinedButton import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardEvent.OpenRangePicker import com.woocommerce.android.ui.dashboard.blaze.DashboardBlazeCard import com.woocommerce.android.ui.dashboard.onboarding.DashboardOnboardingCard +import com.woocommerce.android.ui.dashboard.reviews.DashboardReviewsCard import com.woocommerce.android.ui.dashboard.stats.DashboardStatsCard import com.woocommerce.android.ui.dashboard.topperformers.DashboardTopPerformersWidgetCard @@ -132,6 +133,11 @@ private fun ConfigurableWidgetCard( parentViewModel = dashboardViewModel, modifier = modifier ) + + DashboardWidget.Type.REVIEWS -> DashboardReviewsCard( + parentViewModel = dashboardViewModel, + modifier = modifier + ) } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt index edf6294c2b0..ed65ffd2774 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardDataStore.kt @@ -6,6 +6,7 @@ import com.woocommerce.android.model.DashboardWidget import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.ui.mystore.data.DashboardDataModel import com.woocommerce.android.ui.mystore.data.DashboardWidgetDataModel +import com.woocommerce.android.util.FeatureFlag import com.woocommerce.android.util.WooLog import com.woocommerce.android.util.WooLog.T import dagger.hilt.EntryPoints @@ -77,4 +78,8 @@ class DashboardDataStore @Inject constructor( // Use the feature flag [DYNAMIC_DASHBOARD_M2] to filter out unsupported widgets during development private val supportedWidgets: List = DashboardWidget.Type.entries + .filter { + FeatureFlag.DYNAMIC_DASHBOARD_M2.isEnabled() || + it != DashboardWidget.Type.REVIEWS + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardRepository.kt index a8d70de26fd..d276b28b3de 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/data/DashboardRepository.kt @@ -93,6 +93,8 @@ class DashboardRepository @Inject constructor( DashboardWidget.Type.BLAZE -> blazeWidgetStatus DashboardWidget.Type.ONBOARDING -> onboardingWidgetStatus + + else -> DashboardWidget.Status.Available } ) } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt new file mode 100644 index 00000000000..fc545000492 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsCard.kt @@ -0,0 +1,64 @@ +package com.woocommerce.android.ui.dashboard.reviews + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import com.woocommerce.android.model.DashboardWidget +import com.woocommerce.android.ui.compose.viewModelWithFactory +import com.woocommerce.android.ui.dashboard.DashboardViewModel +import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMenu +import com.woocommerce.android.ui.dashboard.WidgetCard +import com.woocommerce.android.ui.dashboard.WidgetError +import com.woocommerce.android.ui.dashboard.defaultHideMenuEntry + +@Composable +fun DashboardReviewsCard( + parentViewModel: DashboardViewModel, + modifier: Modifier = Modifier, + viewModel: DashboardReviewsViewModel = viewModelWithFactory { factory: DashboardReviewsViewModel.Factory -> + factory.create(parentViewModel = parentViewModel) + } +) { + WidgetCard( + titleResource = DashboardWidget.Type.REVIEWS.titleResource, + menu = DashboardWidgetMenu( + listOf( + DashboardWidget.Type.REVIEWS.defaultHideMenuEntry { + parentViewModel.onHideWidgetClicked( + DashboardWidget.Type.REVIEWS + ) + } + ) + ), + isError = false, + modifier = modifier + ) { + viewModel.viewState.observeAsState().value?.let { viewState -> + when (viewState) { + is DashboardReviewsViewModel.ViewState.Loading -> { + CircularProgressIndicator() + } + + is DashboardReviewsViewModel.ViewState.Success -> { + Column { + viewState.reviews.forEach { review -> + Text(text = review.review) + Divider() + } + } + } + + is DashboardReviewsViewModel.ViewState.Error -> { + WidgetError( + onContactSupportClicked = { /*TODO*/ }, + onRetryClicked = { /*TODO*/ } + ) + } + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsViewModel.kt new file mode 100644 index 00000000000..8462cacb14c --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/dashboard/reviews/DashboardReviewsViewModel.kt @@ -0,0 +1,91 @@ +package com.woocommerce.android.ui.dashboard.reviews + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.woocommerce.android.model.ProductReview +import com.woocommerce.android.ui.dashboard.DashboardViewModel +import com.woocommerce.android.ui.reviews.ProductReviewStatus +import com.woocommerce.android.ui.reviews.ReviewListRepository +import com.woocommerce.android.viewmodel.ScopedViewModel +import com.woocommerce.android.viewmodel.getStateFlow +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest + +@HiltViewModel(assistedFactory = DashboardReviewsViewModel.Factory::class) +class DashboardReviewsViewModel @AssistedInject constructor( + savedStateHandle: SavedStateHandle, + @Assisted private val parentViewModel: DashboardViewModel, + private val reviewListRepository: ReviewListRepository +) : ScopedViewModel(savedStateHandle) { + private val refreshTrigger = parentViewModel.refreshTrigger + .onStart { emit(DashboardViewModel.RefreshEvent()) } + private val status = savedStateHandle.getStateFlow(viewModelScope, ProductReviewStatus.ALL) + + @OptIn(ExperimentalCoroutinesApi::class) + val viewState = combine(refreshTrigger, status) { refresh, status -> Pair(refresh, status) } + .transformLatest { (refresh, status) -> + emit(ViewState.Loading) + emitAll( + observeMostRecentReviews(forceRefresh = refresh.isForced, status = status) + .map { result -> + result.fold( + onSuccess = { reviews -> + ViewState.Success(reviews) + }, + onFailure = { ViewState.Error } + ) + } + ) + } + .asLiveData() + + private fun observeMostRecentReviews( + forceRefresh: Boolean, + status: ProductReviewStatus + ) = flow>> { + if (forceRefresh) { + reviewListRepository.fetchMostRecentReviews(status) + .onFailure { + emit(Result.failure(it)) + return@flow + } + } + + emit(Result.success(getCachedReviews(status))) + + if (!forceRefresh) { + reviewListRepository.fetchMostRecentReviews(status) + .onFailure { + emit(Result.failure(it)) + } + emit(Result.success(getCachedReviews(status))) + } + } + + @Suppress("MagicNumber") + private suspend fun getCachedReviews(status: ProductReviewStatus) = + reviewListRepository.getCachedProductReviews() + .filter { status == ProductReviewStatus.ALL || it.status == status.toString() } + .take(3) + + sealed interface ViewState { + data object Loading : ViewState + data class Success(val reviews: List) : ViewState + data object Error : ViewState + } + + @AssistedFactory + interface Factory { + fun create(parentViewModel: DashboardViewModel): DashboardReviewsViewModel + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewListRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewListRepository.kt index db578904b69..c1fde20e8ed 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewListRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/reviews/ReviewListRepository.kt @@ -1,6 +1,7 @@ package com.woocommerce.android.ui.reviews import com.woocommerce.android.AppConstants +import com.woocommerce.android.OnChangedException import com.woocommerce.android.analytics.AnalyticsEvent import com.woocommerce.android.analytics.AnalyticsTracker import com.woocommerce.android.extensions.getCommentId @@ -11,11 +12,11 @@ import com.woocommerce.android.model.RequestResult.* import com.woocommerce.android.model.toAppModel import com.woocommerce.android.tools.SelectedSite import com.woocommerce.android.util.ContinuationWrapper -import com.woocommerce.android.util.ContinuationWrapper.ContinuationResult.Cancellation -import com.woocommerce.android.util.ContinuationWrapper.ContinuationResult.Success import com.woocommerce.android.util.WooLog import com.woocommerce.android.util.WooLog.T.REVIEWS +import com.woocommerce.android.util.dispatchAndAwait import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow @@ -24,7 +25,6 @@ import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode.MAIN import org.wordpress.android.fluxc.Dispatcher -import org.wordpress.android.fluxc.action.NotificationAction.FETCH_NOTIFICATIONS import org.wordpress.android.fluxc.action.WCProductAction.FETCH_PRODUCTS import org.wordpress.android.fluxc.generated.NotificationActionBuilder import org.wordpress.android.fluxc.generated.WCProductActionBuilder @@ -48,7 +48,6 @@ class ReviewListRepository @Inject constructor( } private var continuationProduct = ContinuationWrapper(REVIEWS) - private var continuationNotification = ContinuationWrapper(REVIEWS) private var offset = 0 private var unreadReviewsOffset = 0 @@ -83,7 +82,11 @@ class ReviewListRepository @Inject constructor( coroutineScope { launch { val fetchNotificationsResult = fetchNotifications() - send(FetchReviewsResult.NotificationsFetched(if (fetchNotificationsResult) SUCCESS else ERROR)) + send( + FetchReviewsResult.NotificationsFetched( + if (fetchNotificationsResult.isSuccess) SUCCESS else ERROR + ) + ) } launch { @@ -104,6 +107,46 @@ class ReviewListRepository @Inject constructor( } } + /** + * Fetch the most recent product reviews from the API and notifications. this fetches only the first page + * from the API, and doesn't delete previously cached reviews. + * + * @param [status] the status of the reviews to fetch + * @return the result of the fetch as a [Result] + */ + suspend fun fetchMostRecentReviews( + status: ProductReviewStatus + ): Result = coroutineScope { + val reviewsTask = async { + val payload = WCProductStore.FetchProductReviewsPayload( + site = selectedSite.get(), + filterByStatus = status.takeIf { it != ProductReviewStatus.ALL }?.let { listOf(it.toString()) } + ) + + productStore.fetchProductReviews( + payload = payload, + deletePreviouslyCachedReviews = false + ).let { result -> + if (result.isError) { + Result.failure(OnChangedException(result.error)) + } else { + Result.success(Unit) + } + } + } + + val notificationsTask = async { + fetchNotifications() + } + + reviewsTask.await() + .onFailure { return@coroutineScope Result.failure(it) } + notificationsTask.await() + .onFailure { return@coroutineScope Result.failure(it) } + + Result.success(Unit) + } + /** * Fires the request to mark all product review notifications as read to the API. If there are * no unread product review notifications in the database, then the result will be @@ -259,14 +302,35 @@ class ReviewListRepository @Inject constructor( /** * Fetches notifications from the API. We use these results to populate [ProductReview.read]. */ - private suspend fun fetchNotifications(): Boolean { - val result = continuationNotification.callAndWaitUntilTimeout(AppConstants.REQUEST_TIMEOUT) { - val payload = FetchNotificationsPayload() - dispatcher.dispatch(NotificationActionBuilder.newFetchNotificationsAction(payload)) - } - return when (result) { - is Cancellation -> false - is Success -> result.value + private suspend fun fetchNotifications(): Result { + val payload = FetchNotificationsPayload() + val event = dispatcher.dispatchAndAwait( + NotificationActionBuilder.newFetchNotificationsAction(payload) + ) + + return when { + event.isError -> { + AnalyticsTracker.track( + AnalyticsEvent.NOTIFICATIONS_LOAD_FAILED, + mapOf( + AnalyticsTracker.KEY_ERROR_CONTEXT to this::class.java.simpleName, + AnalyticsTracker.KEY_ERROR_TYPE to event.error?.type?.toString(), + AnalyticsTracker.KEY_ERROR_DESC to event.error?.message + ) + ) + + WooLog.e( + REVIEWS, + "Error fetching product review notifications: " + + "${event.error?.type} - ${event.error?.message}" + ) + Result.failure(OnChangedException(event.error)) + } + + else -> { + AnalyticsTracker.track(AnalyticsEvent.NOTIFICATIONS_LOADED) + Result.success(Unit) + } } } @@ -384,34 +448,6 @@ class ReviewListRepository @Inject constructor( } } - @SuppressWarnings("unused") - @Subscribe(threadMode = MAIN) - fun onNotificationChanged(event: OnNotificationChanged) { - if (event.causeOfChange == FETCH_NOTIFICATIONS) { - if (event.isError) { - AnalyticsTracker.track( - AnalyticsEvent.NOTIFICATIONS_LOAD_FAILED, - mapOf( - AnalyticsTracker.KEY_ERROR_CONTEXT to this::class.java.simpleName, - AnalyticsTracker.KEY_ERROR_TYPE to event.error?.type?.toString(), - AnalyticsTracker.KEY_ERROR_DESC to event.error?.message - ) - ) - - WooLog.e( - REVIEWS, - "Error fetching product review notifications: " + - "${event.error?.type} - ${event.error?.message}" - ) - continuationNotification.continueWith(false) - } else { - AnalyticsTracker.track(AnalyticsEvent.NOTIFICATIONS_LOADED) - - continuationNotification.continueWith(true) - } - } - } - sealed class FetchReviewsResult { data class ReviewsFetched(val requestResult: RequestResult) : FetchReviewsResult() data class NotificationsFetched(val requestResult: RequestResult) : FetchReviewsResult() diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 1aa7cfaea3d..0921aa10a02 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -410,6 +410,7 @@ Top performers Blaze campaigns Feedback + Most recent reviews Unavailable Completed