Skip to content

[Dynamic Dashboard] Orders card status filtering #11533

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 8 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -14,6 +14,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
Expand All @@ -28,6 +29,7 @@ import com.woocommerce.android.ui.compose.animations.SkeletonView
import com.woocommerce.android.ui.compose.component.WCTag
import com.woocommerce.android.ui.compose.rememberNavController
import com.woocommerce.android.ui.compose.viewModelWithFactory
import com.woocommerce.android.ui.dashboard.DashboardFilterableCardHeader
import com.woocommerce.android.ui.dashboard.DashboardViewModel
import com.woocommerce.android.ui.dashboard.WidgetCard
import com.woocommerce.android.ui.dashboard.WidgetError
Expand All @@ -36,6 +38,7 @@ import com.woocommerce.android.ui.dashboard.orders.DashboardOrdersViewModel.View
import com.woocommerce.android.ui.dashboard.orders.DashboardOrdersViewModel.ViewState.Error
import com.woocommerce.android.ui.dashboard.orders.DashboardOrdersViewModel.ViewState.Loading
import com.woocommerce.android.ui.dashboard.orders.DashboardOrdersViewModel.ViewState.OrderItem
import com.woocommerce.android.ui.orders.filters.data.OrderStatusOption
import com.woocommerce.android.viewmodel.MultiLiveEvent.Event

@Composable
Expand All @@ -57,6 +60,9 @@ fun DashboardOrdersCard(
when (state) {
is Content -> {
TopOrders(
selectedFilter = state.selectedFilter,
filterOptions = state.filterOptions,
onFilterSelected = viewModel::onFilterSelected,
orders = state.orders
)
}
Expand Down Expand Up @@ -103,11 +109,39 @@ private fun HandleEvents(
}
}

@Composable
private fun Header(
selectedFilter: OrderStatusOption,
filterOptions: List<OrderStatusOption>,
onFilterSelected: (OrderStatusOption) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier) {
DashboardFilterableCardHeader(
title = stringResource(id = R.string.dashboard_reviews_card_header_title),
currentFilter = selectedFilter,
filterList = filterOptions,
onFilterSelected = onFilterSelected,
mapper = { it.label }
)

Divider()
}
}

@Composable
fun TopOrders(
selectedFilter: OrderStatusOption,
filterOptions: List<OrderStatusOption>,
onFilterSelected: (OrderStatusOption) -> Unit,
orders: List<OrderItem>
) {
Column {
Header(
selectedFilter = selectedFilter,
filterOptions = filterOptions,
onFilterSelected = onFilterSelected
)
orders.forEach { order ->
OrderListItem(order)

Expand Down Expand Up @@ -288,6 +322,27 @@ fun PreviewTopOrders() {
totalPrice = "$200.00"
)
),
selectedFilter = OrderStatusOption(
key = "processing",
label = "Processing",
statusCount = 1,
isSelected = true
),
filterOptions = listOf(
OrderStatusOption(
key = "processing",
label = "Processing",
statusCount = 1,
isSelected = true
),
OrderStatusOption(
key = "completed",
label = "Completed",
statusCount = 1,
isSelected = false
)
),
onFilterSelected = {}
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,25 @@ import com.woocommerce.android.R
import com.woocommerce.android.analytics.AnalyticsEvent
import com.woocommerce.android.analytics.AnalyticsTracker
import com.woocommerce.android.analytics.AnalyticsTrackerWrapper
import com.woocommerce.android.extensions.capitalize
import com.woocommerce.android.extensions.formatToMMMdd
import com.woocommerce.android.model.DashboardWidget
import com.woocommerce.android.model.DashboardWidget.Type.ORDERS
import com.woocommerce.android.model.Order
import com.woocommerce.android.model.toOrderStatus
import com.woocommerce.android.ui.dashboard.DashboardViewModel
import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetAction
import com.woocommerce.android.ui.dashboard.DashboardViewModel.DashboardWidgetMenu
import com.woocommerce.android.ui.dashboard.DashboardViewModel.RefreshEvent
import com.woocommerce.android.ui.dashboard.defaultHideMenuEntry
import com.woocommerce.android.ui.dashboard.orders.DashboardOrdersViewModel.Factory
import com.woocommerce.android.ui.dashboard.orders.DashboardOrdersViewModel.ViewState.Content
import com.woocommerce.android.ui.orders.filters.data.OrderStatusOption
import com.woocommerce.android.ui.orders.filters.domain.GetOrderStatusFilterOptions
import com.woocommerce.android.ui.orders.list.OrderListRepository
import com.woocommerce.android.util.CurrencyFormatter
import com.woocommerce.android.viewmodel.MultiLiveEvent
import com.woocommerce.android.viewmodel.ResourceProvider
import com.woocommerce.android.viewmodel.ScopedViewModel
import com.woocommerce.android.viewmodel.getStateFlow
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
Expand All @@ -35,27 +36,34 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import java.util.Locale

@Suppress("LongParameterList")
@HiltViewModel(assistedFactory = Factory::class)
class DashboardOrdersViewModel @AssistedInject constructor(
savedStateHandle: SavedStateHandle,
@Assisted private val parentViewModel: DashboardViewModel,
private val orderListRepository: OrderListRepository,
private val analyticsTrackerWrapper: AnalyticsTrackerWrapper,
private val currencyFormatter: CurrencyFormatter,
private val resourceProvider: ResourceProvider
private val resourceProvider: ResourceProvider,
private val getOrderStatusFilterOptions: GetOrderStatusFilterOptions
) : ScopedViewModel(savedStateHandle) {
companion object {
const val MAX_NUMBER_OF_ORDERS_TO_DISPLAY_IN_CARD = 3
const val DEFAULT_FILTER_OPTION = "all"
}

private val orderStatusMap = MutableSharedFlow<Map<String, Order.OrderStatus>>(replay = 1)
private val refreshTrigger = MutableSharedFlow<RefreshEvent>(extraBufferCapacity = 1)
private val statusOptions = MutableSharedFlow<List<OrderStatusOption>>(replay = 1)
Copy link
Member

Choose a reason for hiding this comment

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

np, given the usage of a SharedFlow here, if we don't have any cached values for the order status options, we won't show emit the UI state until the API call to fetch them is done.

WDYT about replacing the MutableSharedFlow with a MutableStateFlow that starts with listOf(DEFAULT_FILTER_OPTION...)? This way we'll start with all as a single entry at the beginning.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a clever idea 👍

private val _refreshTrigger = MutableSharedFlow<RefreshEvent>(extraBufferCapacity = 1)
private val refreshTrigger = merge(parentViewModel.refreshTrigger, _refreshTrigger)
.onStart { emit(RefreshEvent()) }
private val selectedFilter = savedStateHandle.getStateFlow(viewModelScope, DEFAULT_FILTER_OPTION)

val menu = DashboardWidgetMenu(
items = listOf(
Expand All @@ -71,49 +79,69 @@ class DashboardOrdersViewModel @AssistedInject constructor(
)

@OptIn(ExperimentalCoroutinesApi::class)
val viewState = merge(parentViewModel.refreshTrigger, refreshTrigger)
.onStart { emit(RefreshEvent()) }
.transformLatest {
val viewState = selectedFilter.flatMapLatest { status ->
refreshTrigger.map { Pair(status, it) }
}.transformLatest { (filterStatus, refresh) ->
if (refresh.isForced) {
Copy link
Member

Choose a reason for hiding this comment

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

np, wouldn't the check for forced here cause issues on initial load if we don't have any cached orders? I think that as we won't emit anything, we won't have any UI until the orders fetch is done, or I am missing something 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, I added it to avoid the ugly flashing when changing the status filter. I added a check for this.

Copy link
Member

Choose a reason for hiding this comment

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

Nice

emit(ViewState.Loading)
emitAll(
}
emitAll(
combine(
orderListRepository.observeTopOrders(
count = MAX_NUMBER_OF_ORDERS_TO_DISPLAY_IN_CARD,
isForced = it.isForced
).combine(orderStatusMap) { result, statusMap ->
result.fold(
onSuccess = { orders ->
Content(
orders.map { order ->
val status = statusMap[order.status.value]?.label
?: order.status.value.capitalize(Locale.getDefault())

ViewState.OrderItem(
number = "#${order.number}",
date = order.dateCreated.formatToMMMdd(),
customerName = order.billingName.ifEmpty {
resourceProvider.getString(R.string.orderdetail_customer_name_default)
},
status = status,
statusColor = order.status.color,
totalPrice = currencyFormatter.formatCurrency(order.total, order.currency)
)
}
)
},
onFailure = { error ->
ViewState.Error(error.message ?: "")
}
)
}
)
}.asLiveData()
isForced = refresh.isForced,
statusFilter = filterStatus
.takeIf { it != DEFAULT_FILTER_OPTION }
?.let { Order.Status.fromValue(it) }
),
statusOptions
) { result, statusOptions ->
result.fold(
onSuccess = { orders ->
Content(
orders = orders.map { order ->
val status = statusOptions
.first { option -> option.key == order.status.value }.label

ViewState.OrderItem(
number = "#${order.number}",
date = order.dateCreated.formatToMMMdd(),
customerName = order.billingName.ifEmpty {
resourceProvider.getString(R.string.orderdetail_customer_name_default)
},
status = status,
statusColor = order.status.color,
totalPrice = currencyFormatter.formatCurrency(order.total, order.currency)
)
},
filterOptions = statusOptions,
selectedFilter = statusOptions.first { it.key == filterStatus }
)
},
onFailure = { error ->
ViewState.Error(error.message ?: "")
}
)
}
)
}.asLiveData()

init {
viewModelScope.launch {
orderStatusMap.tryEmit(
orderListRepository.getCachedOrderStatusOptions().mapValues { (_, value) ->
value.toOrderStatus()
}
statusOptions.tryEmit(
getOrderStatusFilterOptions()
.toMutableList()
.apply {
this.add(
index = 0,
element = OrderStatusOption(
key = DEFAULT_FILTER_OPTION,
label = resourceProvider.getString(R.string.orderfilters_default_filter_value),
statusCount = 0,
isSelected = true
)
)
}
)
}
}
Expand All @@ -140,13 +168,21 @@ class DashboardOrdersViewModel @AssistedInject constructor(
AnalyticsTracker.KEY_TYPE to DashboardWidget.Type.ORDERS.trackingIdentifier
)
)
refreshTrigger.tryEmit(RefreshEvent(isForced = true))
_refreshTrigger.tryEmit(RefreshEvent(isForced = true))
}

fun onFilterSelected(filter: OrderStatusOption) {
selectedFilter.value = filter.key
}

sealed class ViewState {
data object Loading : ViewState()
data class Error(val message: String) : ViewState()
data class Content(val orders: List<OrderItem>) : ViewState()
data class Content(
val orders: List<OrderItem>,
val filterOptions: List<OrderStatusOption>,
val selectedFilter: OrderStatusOption
) : ViewState()

@StringRes val title: Int = ORDERS.titleResource

Expand Down
Loading