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 index beae915c0fb..ad54c9a9289 100644 --- 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 @@ -2,6 +2,7 @@ package com.woocommerce.android.ui.dashboard.reviews import android.widget.RatingBar import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -47,7 +48,6 @@ import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMe import com.woocommerce.android.ui.dashboard.WidgetCard import com.woocommerce.android.ui.dashboard.WidgetError import com.woocommerce.android.ui.dashboard.defaultHideMenuEntry -import com.woocommerce.android.ui.dashboard.reviews.DashboardReviewsViewModel.OpenReviewsList import com.woocommerce.android.ui.reviews.ProductReviewStatus import com.woocommerce.android.util.StringUtils import com.woocommerce.android.viewmodel.MultiLiveEvent @@ -68,6 +68,7 @@ fun DashboardReviewsCard( onHideClicked = { parentViewModel.onHideWidgetClicked(DashboardWidget.Type.REVIEWS) }, onFilterSelected = viewModel::onFilterSelected, onViewAllClicked = viewModel::onViewAllClicked, + onReviewClicked = viewModel::onReviewClicked, onContactSupportClicked = parentViewModel::onContactSupportClicked, onRetryClicked = viewModel::onRetryClicked, modifier = modifier @@ -83,9 +84,23 @@ private fun HandleEvents(event: LiveData) { DisposableEffect(event, navController, lifecycleOwner) { val observer = Observer { event: MultiLiveEvent.Event -> when (event) { - OpenReviewsList -> navController.navigateSafely( + is DashboardReviewsViewModel.OpenReviewsList -> navController.navigateSafely( DashboardFragmentDirections.actionDashboardToReviews() ) + is DashboardReviewsViewModel.OpenReviewDetail -> { + // Open the review list screen first as it's responsible for handling review status changes + navController.navigateSafely( + DashboardFragmentDirections.actionDashboardToReviews() + ) + // Continue to the details screen + navController.navigateSafely( + directions = DashboardFragmentDirections.actionGlobalReviewDetailFragment( + launchedFromNotification = false, + remoteReviewId = event.review.remoteId + ), + skipThrottling = true + ) + } } } @@ -103,6 +118,7 @@ private fun DashboardReviewsCard( onHideClicked: () -> Unit, onFilterSelected: (ProductReviewStatus) -> Unit, onViewAllClicked: () -> Unit, + onReviewClicked: (ProductReview) -> Unit, onContactSupportClicked: () -> Unit, onRetryClicked: () -> Unit, modifier: Modifier @@ -132,7 +148,8 @@ private fun DashboardReviewsCard( is DashboardReviewsViewModel.ViewState.Success -> { ProductReviewsCardContent( viewState = viewState, - onFilterSelected = onFilterSelected + onFilterSelected = onFilterSelected, + onReviewClicked = onReviewClicked ) } @@ -150,6 +167,7 @@ private fun DashboardReviewsCard( private fun ProductReviewsCardContent( viewState: DashboardReviewsViewModel.ViewState.Success, onFilterSelected: (ProductReviewStatus) -> Unit, + onReviewClicked: (ProductReview) -> Unit, modifier: Modifier = Modifier ) { Column(modifier) { @@ -161,7 +179,8 @@ private fun ProductReviewsCardContent( viewState.reviews.forEachIndexed { index, productReview -> ReviewListItem( review = productReview, - showDivider = index < viewState.reviews.size - 1 + showDivider = index < viewState.reviews.size - 1, + onClicked = { onReviewClicked(productReview) } ) } } @@ -172,11 +191,15 @@ private fun ProductReviewsCardContent( private fun ReviewListItem( review: ProductReview, showDivider: Boolean, + onClicked: () -> Unit, modifier: Modifier = Modifier ) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp) + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClicked) + .padding(horizontal = 16.dp, vertical = 8.dp) ) { Icon( painter = painterResource(id = R.drawable.ic_comment), 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 index d0d310e5016..542207062a0 100644 --- 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 @@ -7,6 +7,8 @@ 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.ui.reviews.ReviewModerationHandler +import com.woocommerce.android.ui.reviews.ReviewModerationStatus import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ScopedViewModel import com.woocommerce.android.viewmodel.getStateFlow @@ -15,29 +17,33 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = DashboardReviewsViewModel.Factory::class) class DashboardReviewsViewModel @AssistedInject constructor( savedStateHandle: SavedStateHandle, @Assisted private val parentViewModel: DashboardViewModel, - private val reviewListRepository: ReviewListRepository + private val reviewListRepository: ReviewListRepository, + private val reviewModerationHandler: ReviewModerationHandler ) : ScopedViewModel(savedStateHandle) { companion object { val supportedFilters = listOf( ProductReviewStatus.ALL, ProductReviewStatus.APPROVED, - ProductReviewStatus.HOLD, - ProductReviewStatus.SPAM + ProductReviewStatus.HOLD ) } + private val _refreshTrigger = MutableSharedFlow(extraBufferCapacity = 1) private val refreshTrigger = merge(parentViewModel.refreshTrigger, _refreshTrigger) .onStart { emit(DashboardViewModel.RefreshEvent()) } @@ -72,6 +78,10 @@ class DashboardReviewsViewModel @AssistedInject constructor( triggerEvent(OpenReviewsList) } + fun onReviewClicked(review: ProductReview) { + triggerEvent(OpenReviewDetail(review)) + } + fun onRetryClicked() { _refreshTrigger.tryEmit(DashboardViewModel.RefreshEvent()) } @@ -79,33 +89,62 @@ class DashboardReviewsViewModel @AssistedInject constructor( private fun observeMostRecentReviews( forceRefresh: Boolean, status: ProductReviewStatus - ) = flow>> { - val fetchBeforeEmit = forceRefresh || getCachedReviews(status).isEmpty() + ) = channelFlow>> { + val fetchBeforeEmit = forceRefresh || observeCachedReviews(status).first().isEmpty() if (fetchBeforeEmit) { reviewListRepository.fetchMostRecentReviews(status) .onFailure { - emit(Result.failure(it)) - return@flow + send(Result.failure(it)) + return@channelFlow } } - emit(Result.success(getCachedReviews(status))) + coroutineScope { + val cacheJob = launch { + observeCachedReviews(status) + .collect { cachedReviews -> + send(Result.success(cachedReviews)) + } + } - if (!fetchBeforeEmit) { - reviewListRepository.fetchMostRecentReviews(status) - .onFailure { - emit(Result.failure(it)) - } - emit(Result.success(getCachedReviews(status))) + if (!fetchBeforeEmit) { + reviewListRepository.fetchMostRecentReviews(status) + .onFailure { + cacheJob.cancel() + send(Result.failure(it)) + } + } } } @Suppress("MagicNumber") - private suspend fun getCachedReviews(status: ProductReviewStatus) = - reviewListRepository.getCachedProductReviews() - .filter { status == ProductReviewStatus.ALL || it.status == status.toString() } - .take(3) + private suspend fun observeCachedReviews(status: ProductReviewStatus) = + reviewModerationHandler.pendingModerationStatus.map { moderationStatus -> + val cachedReviews = reviewListRepository.getCachedProductReviews() + .filter { status == ProductReviewStatus.ALL || it.status == status.toString() } + // We need just 3 review, but we will take an additional review to account for + // any pending moderation requests + .take(4) + + cachedReviews.applyModerationStatus(moderationStatus) + .take(3) + } + + private fun List.applyModerationStatus( + moderationStatus: List + ): List { + return map { review -> + val status = moderationStatus.firstOrNull { it.review.remoteId == review.remoteId } + if (status != null) { + review.copy(status = status.newStatus.toString()) + } else { + review + } + }.filter { + it.status != ProductReviewStatus.TRASH.toString() && it.status != ProductReviewStatus.SPAM.toString() + } + } sealed interface ViewState { data class Loading(val selectedFilter: ProductReviewStatus) : ViewState @@ -118,6 +157,7 @@ class DashboardReviewsViewModel @AssistedInject constructor( } data object OpenReviewsList : MultiLiveEvent.Event() + data class OpenReviewDetail(val review: ProductReview) : MultiLiveEvent.Event() @AssistedFactory interface Factory { diff --git a/WooCommerce/src/main/res/navigation/nav_graph_main.xml b/WooCommerce/src/main/res/navigation/nav_graph_main.xml index 0367cc64f75..1e2c5afec36 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_main.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_main.xml @@ -419,7 +419,7 @@ app:argType="long" />