Skip to content

Conversation

@toupper
Copy link
Contributor

@toupper toupper commented Sep 5, 2025

Description

With this PR we implement Pull to Refresh on POS Orders.

Steps to reproduce

  1. Open the app
  2. Go to POS
  3. Go to Orders
  4. Pull to Refresh
  5. Verify that we request orders remotely and that we update the list

Testing information

Tested on Samsung Tab A8 Android 14

Images/gif

Screen_Recording_20250904_163607_Woo.Dev.mp4
  • I have considered if this change warrants release notes and have added them to RELEASE-NOTES.txt if necessary. Use the "[Internal]" label for non-user-facing changes.

@toupper toupper marked this pull request as draft September 5, 2025 09:18
@dangermattic
Copy link
Collaborator

dangermattic commented Sep 5, 2025

1 Warning
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger

… into feat/WOOMOB-1149-pos-historical-orders-ptr

# Conflicts:
#	WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/orders/WooPosOrdersDataSource.kt
@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Sep 5, 2025

📲 You can test the changes from this Pull Request in WooCommerce-Wear Android by scanning the QR code below to install the corresponding build.
App Name WooCommerce-Wear Android
Platform⌚️ Wear OS
FlavorJalapeno
Build TypeDebug
Commit6f414cc
Direct Downloadwoocommerce-wear-prototype-build-pr14578-6f414cc.apk

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Sep 5, 2025

📲 You can test the changes from this Pull Request in WooCommerce Android by scanning the QR code below to install the corresponding build.

App Name WooCommerce Android
Platform📱 Mobile
FlavorJalapeno
Build TypeDebug
Commit6f414cc
Direct Downloadwoocommerce-prototype-build-pr14578-6f414cc.apk

@codecov-commenter
Copy link

codecov-commenter commented Sep 5, 2025

Codecov Report

❌ Patch coverage is 87.50000% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.04%. Comparing base (9720e3c) to head (6f414cc).
⚠️ Report is 337 commits behind head on trunk.

Files with missing lines Patch % Lines
.../android/ui/woopos/orders/WooPosOrdersViewModel.kt 80.95% 4 Missing and 4 partials ⚠️
...android/ui/woopos/orders/WooPosOrdersDataSource.kt 85.71% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… 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
Base automatically changed from feat/WOOMOB-1145-pos-historical-orders-in-memory-cache to trunk September 5, 2025 14:59
@toupper toupper requested a review from kidinov September 5, 2025 15:43
@toupper toupper changed the title Feat/woomob 1149 pos historical orders ptr [POS Historical Orders] Pull to Refresh Sep 5, 2025
@toupper toupper added the type: task An internally driven task. label Sep 5, 2025
@toupper toupper added this to the 23.3 milestone Sep 5, 2025
@toupper toupper marked this pull request as ready for review September 5, 2025 15:45
@kidinov kidinov self-assigned this Sep 8, 2025
BackHandler { onNavigationEvent(WooPosNavigationEvent.GoBack) }

Row(modifier = Modifier.fillMaxSize()) {
// Left pane
Copy link
Contributor

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.

Copy link
Contributor

@kidinov kidinov Sep 8, 2025

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

Copy link
Contributor Author

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()

Copy link
Contributor

Choose a reason for hiding this comment

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

Not related to the PR: on line 55 there is still ?: applied to a non-nullable type
image

Copy link
Contributor Author

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 {
Copy link
Contributor

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?

Copy link
Contributor Author

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) {
Copy link
Contributor

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)
}

Copy link
Contributor Author

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()
Copy link
Contributor

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.

Copy link
Contributor

@kidinov kidinov Sep 8, 2025

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))
        }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Awesome, done in 73bec2d

Copy link
Contributor

@kidinov kidinov left a 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.

@toupper toupper requested a review from kidinov September 9, 2025 13:07
state = state,
onBackClicked = onBackClicked,
onRefresh = viewModel::refresh,
isRefreshing = viewModel.isRefreshing(),
Copy link
Contributor

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for that @kidinov! I applied it in 7cf9f93

(state.value as? WooPosOrdersState.Content)
?.pullToRefreshState == WooPosPullToRefreshState.Refreshing

fun refresh() {
Copy link
Contributor

@kidinov kidinov Sep 10, 2025

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

Copy link
Contributor Author

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

Copy link
Contributor

@kidinov kidinov left a 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

@toupper toupper merged commit cd0480a into trunk Sep 10, 2025
17 checks passed
@toupper toupper deleted the feat/WOOMOB-1149-pos-historical-orders-ptr branch September 10, 2025 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: task An internally driven task.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants