Skip to content
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 @@ -4,10 +4,12 @@ import androidx.annotation.VisibleForTesting
import com.woocommerce.android.ui.bookings.Booking
import com.woocommerce.android.ui.bookings.BookingsRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand All @@ -22,6 +24,7 @@ class BookingListHandler @Inject constructor(
companion object {
@VisibleForTesting
const val PAGE_SIZE = 25
private const val MIN_FETCH_DURATION_MS = 100L
}

private val mutex = Mutex()
Expand All @@ -48,7 +51,7 @@ class BookingListHandler @Inject constructor(
order = sortBy.toBookingsOrderOption()
)
} else {
searchResults
searchResults.map { it.take(page * PAGE_SIZE) }
}
}.flatMapLatest { it }

Expand All @@ -61,17 +64,21 @@ class BookingListHandler @Inject constructor(
page.value = 1
canLoadMore.set(true)

val previousQuery = this.searchQuery.value
this.searchQuery.value = searchQuery
this.filters.value = filters
this.sortBy.value = sortBy

return@withLock if (searchQuery == null) {
fetchBookings()
} else {
searchResults.value = emptyList()
if (searchQuery != previousQuery) {
searchResults.value = emptyList()
}
Comment on lines +75 to +77
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 is the main part of the second change mentioned in the description, it fixes an issue where pull-to-refresh would result in displaying the Empty state during the fetch. I added two unit tests to confirm the behavior too.

Check the below recordings to see the diffference:

Before After
Screen_recording_20251017_181115.mp4
Screen_recording_20251017_181007.mp4

if (searchQuery.isEmpty()) {
// If the query is empty, return cached results directly
canLoadMore.set(false)
// Mimic network delay to allow the UI to show then hide the refreshing indicator
delay(MIN_FETCH_DURATION_MS)
Result.success(Unit)
} else {
fetchBookings()
Expand All @@ -85,10 +92,11 @@ class BookingListHandler @Inject constructor(
}

private suspend fun fetchBookings(): Result<Unit> {
val pageToFetch = page.value
val isSearching = !searchQuery.value.isNullOrEmpty()
val order = sortBy.value.toBookingsOrderOption()
return bookingsRepository.fetchBookings(
page = page.value,
page = pageToFetch,
perPage = PAGE_SIZE,
query = searchQuery.value,
filters = filters.value,
Expand All @@ -99,7 +107,11 @@ class BookingListHandler @Inject constructor(
page.update { it + 1 }
}
if (isSearching) {
searchResults.update { it + result.bookings }
if (pageToFetch == 1) {
searchResults.value = result.bookings
} else {
searchResults.update { it + result.bookings }
}
}
}.map { }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@ import com.woocommerce.android.viewmodel.ScopedViewModel
import com.woocommerce.android.viewmodel.getNullableStateFlow
import com.woocommerce.android.viewmodel.getStateFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingFilters
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption
import javax.inject.Inject

Expand All @@ -42,6 +45,7 @@ class BookingListViewModel @Inject constructor(
clazz = String::class.java,
key = "searchQuery"
)
private val refreshTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)

private val sortOption = savedStateHandle.getStateFlow(viewModelScope, BookingListSortOption.NewestToOldest)

Expand All @@ -59,7 +63,7 @@ class BookingListViewModel @Inject constructor(
BookingListContentState(
bookings = bookings,
loadingState = loadingState,
onRefresh = { fetchBookings(BookingListLoadingState.Refreshing) },
onRefresh = { refreshTrigger.tryEmit(Unit) },
onLoadMore = ::loadMore,
onBookingClick = ::onBookingClick
)
Expand Down Expand Up @@ -112,40 +116,70 @@ class BookingListViewModel @Inject constructor(
monitorSearchAndFilterChanges()
}

@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private fun monitorSearchAndFilterChanges() {
launch {
var lastFetchParams: FetchParams? = null
val queryFlow = searchQuery
.drop(1) // Skip the initial value to avoid double fetch on init
.debounce {
if (it.isNullOrEmpty()) 0L else AppConstants.SEARCH_TYPING_DELAY_MS
.withIndex()
.debounce { (index, query) ->
// Skip debounce for the initial value or when the query is cleared
if (index == 0 || query.isNullOrEmpty()) 0L else AppConstants.SEARCH_TYPING_DELAY_MS
}
val sortFlow = sortOption.drop(1) // Skip the initial value to avoid double fetch on init
val bookingFilterFlow =
bookingFilterRepository.bookingFiltersFlow.drop(1) // Skip initial to avoid double fetch on init

merge(selectedTab, queryFlow, sortFlow, bookingFilterFlow).collectLatest {
.map { it.value }

combine(
selectedTab,
queryFlow,
sortOption,
bookingFilterRepository.bookingFiltersFlow
) { tab, query, sort, filters ->
FetchParams(
searchQuery = query,
sortOption = sort,
selectedTab = tab,
filters = filters
)
}.flatMapLatest { fetchParams ->
refreshTrigger.map { true }
.onStart { emit(false) }
.map { isRefreshing -> Pair(fetchParams, isRefreshing) }
}.collectLatest { (fetchParams, isRefreshing) ->
// Cancel any ongoing fetch or load more operations
bookingsFetchJob?.cancel()
bookingsLoadMoreJob?.cancel()

bookingsFetchJob = fetchBookings(
initialLoadingState = if (it is BookingListSortOption) {
BookingListLoadingState.Refreshing
} else {
BookingListLoadingState.Loading
val initialLoadingState = if (isRefreshing) {
BookingListLoadingState.Refreshing
} else {
lastFetchParams.let { lastFetchParams ->
if (lastFetchParams != null && lastFetchParams.sortOption != fetchParams.sortOption) {
// When sort option changes, force refreshing state to indicate data reload
BookingListLoadingState.Refreshing
} else {
BookingListLoadingState.Loading
}
}
}

lastFetchParams = fetchParams
fetchBookings(
initialLoadingState = initialLoadingState,
fetchParams = fetchParams
)
}
}
}

private fun fetchBookings(initialLoadingState: BookingListLoadingState) = launch {
private suspend fun fetchBookings(
initialLoadingState: BookingListLoadingState,
fetchParams: FetchParams,
) {
Comment on lines +174 to +177
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

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

The fetchBookings function is now suspend but is called from within collectLatest which already runs in a coroutine context. Consider making this a regular function and launching coroutines internally where needed, or ensure proper structured concurrency.

Copilot uses AI. Check for mistakes.
loadingState.value = initialLoadingState
bookingListHandler.loadBookings(
searchQuery = searchQuery.value,
filters = prepareFilters(),
sortBy = sortOption.value
searchQuery = fetchParams.searchQuery,
filters = fetchParams.prepareFilters(),
sortBy = fetchParams.sortOption
).onFailure {
triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.bookings_fetch_error))
}
Expand Down Expand Up @@ -192,17 +226,24 @@ class BookingListViewModel @Inject constructor(
triggerEvent(NavigateToFilters)
}

private suspend fun prepareFilters(): List<BookingsFilterOption> = with(filtersBuilder) {
when (selectedTab.value) {
private fun FetchParams.prepareFilters(): List<BookingsFilterOption> = with(filtersBuilder) {
when (selectedTab) {
BookingListTab.Today,
BookingListTab.Upcoming -> listOfNotNull(
selectedTab.value.asDateRangeFilter()
selectedTab.asDateRangeFilter()
)

BookingListTab.All -> bookingFilterRepository.bookingFiltersFlow.first().asList()
BookingListTab.All -> filters.asList()
}
}

private data class FetchParams(
val searchQuery: String?,
val sortOption: BookingListSortOption,
val selectedTab: BookingListTab,
val filters: BookingFilters
)

data class NavigateToBookingDetails(val bookingId: Long) : MultiLiveEvent.Event()
object NavigateToFilters : MultiLiveEvent.Event()
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,13 @@ fun WCSearchField(
var textFieldValueState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(text = value))
}
// Keep TextFieldValue in sync with external value
val textFieldValue = textFieldValueState.copy(text = value)
Comment on lines +212 to +213
Copy link
Member Author

Choose a reason for hiding this comment

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

Without this, when the value is changed externally (for example by tapping on the clear button), the update is not reflected on the text field.
This line is copied from the BasicTextField String implementation.

Before After
Screen_recording_20251017_181130.mp4
Screen_recording_20251017_181025.mp4


val lastValue by rememberUpdatedState(value)

BasicTextField(
value = textFieldValueState,
value = textFieldValue,
onValueChange = {
textFieldValueState = it
if (it.text != lastValue) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.woocommerce.android.ui.bookings.BookingsRepository
import com.woocommerce.android.util.InlineClassesAnswer
import com.woocommerce.android.viewmodel.BaseUnitTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
Expand All @@ -21,6 +22,7 @@ import org.mockito.kotlin.given
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.willSuspendableAnswer
import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId
import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId
import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingOrderInfo
Expand Down Expand Up @@ -202,6 +204,87 @@ class BookingListHandlerTest : BaseUnitTest() {
assertThat(bookings).hasSize(BookingListHandler.PAGE_SIZE * 2)
}

@Test
fun `given search, when refreshing, then keep previous search results during the fetch`() = testBlocking {
val searchQuery = "Sample"
val firstResults = List(BookingListHandler.PAGE_SIZE) { getSampleBooking(it) }
val refreshedResults = List(BookingListHandler.PAGE_SIZE - 2) { getSampleBooking(it) }
given(bookingsRepository.fetchBookings(any(), any(), eq(searchQuery), any(), any()))
.willReturn(
Result.success(
BookingsRepository.FetchResult(firstResults, false)
)
)
.willSuspendableAnswer {
delay(10) // To allow checking intermediate state
Result.success(
BookingsRepository.FetchResult(refreshedResults, false)
)
}

bookingListHandler.loadBookings(
searchQuery = searchQuery,
sortBy = BookingListSortOption.NewestToOldest
)
val bookingsAfterFirstLoad = bookingListHandler.bookingsFlow.first()
val refreshJob = launch {
bookingListHandler.loadBookings(
searchQuery = searchQuery,
sortBy = BookingListSortOption.NewestToOldest
)
}
val bookingsDuringRefresh = bookingListHandler.bookingsFlow.first()
refreshJob.join()
val bookingsAfterRefresh = bookingListHandler.bookingsFlow.first()

assertThat(bookingsAfterFirstLoad).isEqualTo(firstResults)
assertThat(bookingsDuringRefresh).isEqualTo(firstResults)
assertThat(bookingsAfterRefresh).isEqualTo(refreshedResults)
}

@Test
fun `given search, when changing search query, then reset search results`() = testBlocking {
val initialQuery = "Initial"
val newQuery = "New"

val firstResults = List(BookingListHandler.PAGE_SIZE) { getSampleBooking(it) }
val newResults = List(BookingListHandler.PAGE_SIZE - 3) { getSampleBooking(it) }

given(bookingsRepository.fetchBookings(any(), any(), eq(initialQuery), any(), any()))
.willReturn(
Result.success(
BookingsRepository.FetchResult(firstResults, false)
)
)
given(bookingsRepository.fetchBookings(any(), any(), eq(newQuery), any(), any()))
.willSuspendableAnswer {
delay(10) // To allow checking intermediate state
Result.success(
BookingsRepository.FetchResult(newResults, false)
)
}

bookingListHandler.loadBookings(
searchQuery = initialQuery,
sortBy = BookingListSortOption.NewestToOldest
)
val bookingsAfterInitialSearch = bookingListHandler.bookingsFlow.first()

val refreshJob = launch {
bookingListHandler.loadBookings(
searchQuery = newQuery,
sortBy = BookingListSortOption.NewestToOldest
)
}
val bookingsDuringNewSearch = bookingListHandler.bookingsFlow.first()
refreshJob.join()
val bookingsAfterNewSearch = bookingListHandler.bookingsFlow.first()

assertThat(bookingsAfterInitialSearch).isEqualTo(firstResults)
assertThat(bookingsDuringNewSearch).isEmpty()
assertThat(bookingsAfterNewSearch).isEqualTo(newResults)
}

private fun getSampleBooking(id: Int) = BookingEntity(
id = RemoteId(id.toLong()),
localSiteId = LocalId(1),
Expand Down