Skip to content

[Dynamic Dashboard] Reviews card: business logic #11477

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -132,6 +133,11 @@ private fun ConfigurableWidgetCard(
parentViewModel = dashboardViewModel,
modifier = modifier
)

DashboardWidget.Type.REVIEWS -> DashboardReviewsCard(
parentViewModel = dashboardViewModel,
modifier = modifier
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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> = DashboardWidget.Type.entries
.filter {
FeatureFlag.DYNAMIC_DASHBOARD_M2.isEnabled() ||
it != DashboardWidget.Type.REVIEWS
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class DashboardRepository @Inject constructor(
DashboardWidget.Type.BLAZE -> blazeWidgetStatus

DashboardWidget.Type.ONBOARDING -> onboardingWidgetStatus

else -> DashboardWidget.Status.Available
}
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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*/ }
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +35 to +50
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic follows approach 2 on what we decided, as it's quite simple to implement for cards with existing caching, I think it would make sense to use it even if iOS team don't, WDYT?


private fun observeMostRecentReviews(
forceRefresh: Boolean,
status: ProductReviewStatus
) = flow<Result<List<ProductReview>>> {
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<ProductReview>) : ViewState
data object Error : ViewState
}

@AssistedFactory
interface Factory {
fun create(parentViewModel: DashboardViewModel): DashboardReviewsViewModel
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -48,7 +48,6 @@ class ReviewListRepository @Inject constructor(
}

private var continuationProduct = ContinuationWrapper<Boolean>(REVIEWS)
private var continuationNotification = ContinuationWrapper<Boolean>(REVIEWS)

private var offset = 0
private var unreadReviewsOffset = 0
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Unit> = 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
Expand Down Expand Up @@ -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<Unit> {
val payload = FetchNotificationsPayload()
val event = dispatcher.dispatchAndAwait<FetchNotificationsPayload, OnNotificationChanged>(
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)
}
}
}

Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions WooCommerce/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@
<string name="my_store_widget_top_products_title">Top performers</string>
<string name="my_store_widget_blaze_title">Blaze campaigns</string>
<string name="my_store_widget_feedback_title">Feedback</string>
<string name="my_store_widget_reviews_title">Most recent reviews</string>

<string name="my_store_widget_unavailable">Unavailable</string>
<string name="my_store_widget_onboarding_completed">Completed</string>
Expand Down
Loading