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 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 @@ -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,53 +9,69 @@ 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
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
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_STATUS = "all"
}

private val orderStatusMap = MutableSharedFlow<Map<String, Order.OrderStatus>>(replay = 1)
private val refreshTrigger = MutableSharedFlow<RefreshEvent>(extraBufferCapacity = 1)
private val defaultFilterOption = OrderStatusOption(
key = DEFAULT_FILTER_OPTION_STATUS,
label = resourceProvider.getString(R.string.orderfilters_default_filter_value),
statusCount = 0,
isSelected = true
)

private val statusOptions = MutableStateFlow(listOf(defaultFilterOption))
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_STATUS)

val menu = DashboardWidgetMenu(
items = listOf(
Expand All @@ -71,49 +87,61 @@ 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) ->
val statusFilter = filterStatus
.takeIf { it != DEFAULT_FILTER_OPTION_STATUS }
?.let { Order.Status.fromValue(it) }
val hasOrders = orderListRepository.hasOrdersLocally(statusFilter)
if (refresh.isForced || !hasOrders) {
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 = statusFilter
),
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 { add(0, defaultFilterOption) }
)
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class OrderListRepository @Inject constructor(
if (selectedSite.exists()) {
val statusOptions = orderStore.getOrderStatusOptionsForSite(selectedSite.get())
if (statusOptions.isNotEmpty()) {
statusOptions.map { it.statusKey to it }.toMap()
statusOptions.associateBy { it.statusKey }
} else {
emptyMap()
}
Expand Down Expand Up @@ -126,6 +126,10 @@ class OrderListRepository @Inject constructor(
}
}

suspend fun hasOrdersLocally(statusFilter: Order.Status? = null) =
orderStore.getOrdersForSite(selectedSite.get())
.any { statusFilter == null || it.status == statusFilter.value }

fun observeTopOrders(count: Int, isForced: Boolean, statusFilter: Order.Status? = null) = flow {
if (!isForced) {
orderStore.getOrdersForSite(selectedSite.get())
Expand Down
Loading