-
Notifications
You must be signed in to change notification settings - Fork 136
[POS Historical Orders] Pull to Refresh #14578
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
Conversation
Generated by 🚫 Danger |
… into feat/WOOMOB-1149-pos-historical-orders-ptr # Conflicts: # WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt
📲 You can test the changes from this Pull Request in WooCommerce-Wear Android by scanning the QR code below to install the corresponding build.
|
|
📲 You can test the changes from this Pull Request in WooCommerce Android by scanning the QR code below to install the corresponding build.
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## trunk #14578 +/- ##
============================================
+ Coverage 37.99% 38.04% +0.05%
- Complexity 9453 9466 +13
============================================
Files 2041 2041
Lines 114328 114369 +41
Branches 15162 15170 +8
============================================
+ Hits 43443 43517 +74
+ Misses 66902 66867 -35
- Partials 3983 3985 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
… into feat/WOOMOB-1149-pos-historical-orders-ptr # Conflicts: # WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt # WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt
| BackHandler { onNavigationEvent(WooPosNavigationEvent.GoBack) } | ||
|
|
||
| Row(modifier = Modifier.fillMaxSize()) { | ||
| // Left pane |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NP: Instead of a comment, it is better to have a composable with a nice name that represents the left and right panes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same applies to other comments here; it appears to be a workaround for a decent composition of the code. And some maybe don't have value, e.g.
// PTR indicator at the top of the list area
PullRefreshIndicator(
I think that's a redundant comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the hints. Done in e4d24ab
| } | ||
|
|
||
| fun clearCache() = ordersCache.clear() | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, removed in 3292780
| sealed class LoadOrdersResult { | ||
| data class Success(val orders: List<Order>) : LoadOrdersResult() | ||
| data class Error(val message: String) : LoadOrdersResult() | ||
| sealed interface LoadOrdersResult { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason specifically for interface here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a big reason, changed back to sealed class as that's the convention in our code 4340083
| ) | ||
| } | ||
| val refreshing = | ||
| when (val s = state) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It’s best to keep the logic separate from the view. The view should simply display the data without any complex calculations, the logic should be in the VM. Also, you seem to ignore disabled state here.
So maybe something like that would be cleaner:
val pullRefreshState = rememberPullRefreshState(
refreshing = state.pullToRefreshState == Refreshing,
onRefresh = { viewModel.refresh() },
)
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(
pullRefreshState,
enabled = state.pullToRefreshState != WooPosPullToRefreshState.Disabled
)
) {
@Immutable
sealed class WooPosOrdersState(
open val pullToRefreshState: WooPosPullToRefreshState,
) {
@Immutable
data class Content(
val items: List<OrderItemViewState>,
override val pullToRefreshState: WooPosPullToRefreshState,
val paginationState: WooPosPaginationState,
val selectedOrderId: Long?
) : WooPosOrdersState(pullToRefreshState)
@Immutable
data class Error(
val message: String,
override val pullToRefreshState: WooPosPullToRefreshState = WooPosPullToRefreshState.Disabled,
) : WooPosOrdersState(pullToRefreshState)
@Immutable
data class Loading(
override val pullToRefreshState: WooPosPullToRefreshState = WooPosPullToRefreshState.Disabled,
) : WooPosOrdersState(pullToRefreshState)
@Immutable
data class Empty(
override val pullToRefreshState: WooPosPullToRefreshState = WooPosPullToRefreshState.Enabled,
) : WooPosOrdersState(pullToRefreshState)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enabled state in a5378e8
| init { | ||
| refreshOrders() | ||
| } | ||
| private var orders: List<Order> = emptyList() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why to save orders in 2 places? The same about selectedId. You have to have 1 source of the truth - _state variable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels that all this code can be simpler:
@HiltViewModel
class WooPosOrdersViewModel @Inject constructor(
private val ordersDataSource: WooPosOrdersDataSource
) : ViewModel() {
private val _state = MutableStateFlow<WooPosOrdersState>(WooPosOrdersState.Loading())
val state: StateFlow<WooPosOrdersState> = _state.asStateFlow()
init {
loadOrders()
}
fun onOrderSelected(orderId: Long) {
val currentState = _state.value
if (currentState is WooPosOrdersState.Content) {
_state.value = currentState.copy(
items = currentState.items.map { it.copy(isSelected = it.id == orderId) },
selectedOrderId = orderId
)
}
}
fun refresh() {
val currentState = _state.value
_state.value = when (currentState) {
is WooPosOrdersState.Content -> currentState.copy(
pullToRefreshState = WooPosPullToRefreshState.Refreshing
)
is WooPosOrdersState.Empty -> currentState.copy(
pullToRefreshState = WooPosPullToRefreshState.Refreshing
)
is WooPosOrdersState.Error -> currentState
is WooPosOrdersState.Loading -> currentState
}
ordersDataSource.clearCache()
loadOrders()
}
private fun loadOrders() {
viewModelScope.launch {
ordersDataSource.loadOrders().collect { result ->
when (result) {
is LoadOrdersResult.Error -> {
_state.value = WooPosOrdersState.Error(
message = result.message,
pullToRefreshState = WooPosPullToRefreshState.Disabled
)
}
is LoadOrdersResult.SuccessCache -> {
updateContentState(result.orders)
}
is LoadOrdersResult.SuccessRemote -> {
if (result.orders.isEmpty()) {
_state.value = WooPosOrdersState.Empty()
} else {
updateContentState(result.orders)
}
}
}
}
}
}
private fun updateContentState(orders: List<Order>) {
val currentSelectedId = (_state.value as? WooPosOrdersState.Content)?.selectedOrderId
val newSelectedId = currentSelectedId?.takeIf { id -> orders.any { it.id == id } }
?: orders.firstOrNull()?.id
_state.value = WooPosOrdersState.Content(
items = orders.map { order ->
OrderItemViewState(
id = order.id,
title = "Order #${order.number}",
date = order.dateCreated.formatToDDMMMYYYY(),
total = "${order.total} ${order.currency}",
isSelected = order.id == newSelectedId
)
},
selectedOrderId = newSelectedId,
pullToRefreshState = WooPosPullToRefreshState.Enabled,
paginationState = WooPosPaginationState.None
)
}
}
I think it make sense if WooPosOrdersDataSource won't return SuccessCache if there is nothing in the cache as well:
val cached = ordersCache.getAll()
if (cached.isNotEmpty()) {
emit(LoadOrdersResult.SuccessCache(cached))
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome, done in 73bec2d
WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersViewModel.kt
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @toupper !
I left a few comments! Please take a look. Especially I think we should not have 2 different states in the view model as it source of many bugs having to sync them.
Also, it’s generally not a good idea to use when with else in a sealed class. This can make it harder to catch bugs when a new type is added, as the compiler won't complain
I didn't test the implementation yet, as I want to see what you think about my comments.
| state = state, | ||
| onBackClicked = onBackClicked, | ||
| onRefresh = viewModel::refresh, | ||
| isRefreshing = viewModel.isRefreshing(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue here is that isRefreshing is a function call that derives state from the existing state flow, rather than being part of the state itself. This violates the principle of having a single source of truth for UI state. So basically during composition, in some moment you go to the VM and ask for another piece of state, and there is no guarantee that it will still be the same state that you are rendering at the moment, which may end up with inconsistent state being displayed to the user
Something like that would solve this potential issue:
Refactor_pull-to-refresh_logic_in_WooPosOrders_components.patch
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| (state.value as? WooPosOrdersState.Content) | ||
| ?.pullToRefreshState == WooPosPullToRefreshState.Refreshing | ||
|
|
||
| fun refresh() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
np: onRefresh would follow the convention when the functions are called as a reaction to the user’s action
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, done in ac705b3
kidinov
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! Before merging, please take a look at the comments I left especially the one about isRefreshing method

Description
With this PR we implement Pull to Refresh on POS Orders.
Steps to reproduce
Testing information
Tested on Samsung Tab A8 Android 14
Images/gif
Screen_Recording_20250904_163607_Woo.Dev.mp4
RELEASE-NOTES.txtif necessary. Use the "[Internal]" label for non-user-facing changes.