From f3f81c99de522422988b9b8cd9902fe9cc22c01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Fri, 3 Oct 2025 01:35:58 +0300 Subject: [PATCH 01/11] Introduce BookingsOrderOption for sorting order params --- .../rest/wpcom/wc/bookings/BookingsOrderOption.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt new file mode 100644 index 00000000000..8b7137c91dd --- /dev/null +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings + +/** + * Represents the sort order for bookings. Only two values are allowed by the API: "asc" and "desc". + * + * Use [value] when building query parameters. + */ +enum class BookingsOrderOption(val value: String) { + ASC("asc"), + DESC("desc"); + + override fun toString(): String = value +} From 2d6531c1673b5a37ab7b8e62b82ce399523ce92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Fri, 3 Oct 2025 01:36:15 +0300 Subject: [PATCH 02/11] Pass order param through RestClient and Store --- .../rest/wpcom/wc/bookings/BookingsRestClient.kt | 5 ++++- .../network/rest/wpcom/wc/bookings/BookingsStore.kt | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt index dfc98757239..62cd58f99a7 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsRestClient.kt @@ -22,7 +22,8 @@ class BookingsRestClient @Inject constructor( site: SiteModel, perPage: Int, page: Int, - filters: List + filters: List, + order: BookingsOrderOption ): WooPayload> { val endpoint = WOOCOMMERCE.bookings.pathV2Bookings @@ -31,6 +32,8 @@ class BookingsRestClient @Inject constructor( path = endpoint, clazz = Array::class.java, params = mapOf( + "orderby" to "start_date", + "order" to order.value, "per_page" to perPage.toString(), "page" to page.toString() ) + filters.toQueryParams() diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt index 7374165fde6..b29856f2454 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsStore.kt @@ -28,10 +28,11 @@ class BookingsStore @Inject constructor( site: SiteModel, perPage: Int = BookingsRestClient.DEFAULT_PER_PAGE, page: Int = 1, - filters: List = emptyList() + filters: List = emptyList(), + order: BookingsOrderOption ): WooResult { return coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetchBookings") { - val response = bookingsRestClient.fetchBookings(site, perPage, page, filters) + val response = bookingsRestClient.fetchBookings(site, perPage, page, filters, order) when { response.isError -> WooResult(response.error) response.result != null -> { @@ -57,8 +58,9 @@ class BookingsStore @Inject constructor( fun observeBookings( site: SiteModel, limit: Int? = null, - filters: List = emptyList() - ): Flow> = bookingsDao.observeBookings(site.localId(), limit, filters) + filters: List = emptyList(), + order: BookingsOrderOption + ): Flow> = bookingsDao.observeBookings(site.localId(), limit, filters, order) private fun BookingDto.toEntity(localSiteId: LocalId): BookingEntity = BookingEntity( id = RemoteId(id), From 63ecedd340634911724153c48dab6f525c677d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Fri, 3 Oct 2025 01:36:20 +0300 Subject: [PATCH 03/11] Add order support and sort by start in BookingsDao --- .../fluxc/persistence/dao/BookingsDao.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt index 4bb46eda4af..0f4ae8393a2 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/persistence/dao/BookingsDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import kotlinx.coroutines.flow.Flow import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption +import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsOrderOption import org.wordpress.android.fluxc.persistence.entity.BookingEntity @Dao @@ -18,11 +19,14 @@ interface BookingsDao { AND (:startDateBefore IS NULL OR start < :startDateBefore) AND (:startDateAfter IS NULL OR start > :startDateAfter) AND (:customerId IS NULL OR customerId = :customerId) - ORDER BY dateCreated DESC + ORDER BY + CASE WHEN :order = 'ASC' THEN start END ASC, + CASE WHEN :order = 'DESC' THEN start END DESC LIMIT CASE WHEN :limit IS NULL THEN -1 ELSE :limit END """ } + @Suppress("LongParameterList") @Query(DEFAULT_SELECT_QUERY) fun observeBookings( localSiteId: LocalId, @@ -30,8 +34,10 @@ interface BookingsDao { startDateBefore: Long?, startDateAfter: Long?, customerId: Long?, + order: BookingsOrderOption ): Flow> + @Suppress("LongParameterList") @Query(DEFAULT_SELECT_QUERY) suspend fun getBookings( localSiteId: LocalId, @@ -39,6 +45,7 @@ interface BookingsDao { startDateBefore: Long?, startDateAfter: Long?, customerId: Long?, + order: BookingsOrderOption ): List @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -53,7 +60,8 @@ interface BookingsDao { fun observeBookings( localSiteId: LocalId, limit: Int? = null, - filters: List = emptyList() + filters: List = emptyList(), + order: BookingsOrderOption ): Flow> { val dateRangeFilter = filters.filterIsInstance().firstOrNull() val customerFilter = filters.filterIsInstance().firstOrNull() @@ -64,13 +72,15 @@ interface BookingsDao { startDateBefore = dateRangeFilter?.before?.epochSecond, startDateAfter = dateRangeFilter?.after?.epochSecond, customerId = customerFilter?.customerId, + order = order ) } suspend fun getBookings( localSiteId: LocalId, limit: Int? = null, - filters: List = emptyList() + filters: List = emptyList(), + order: BookingsOrderOption ): List { val dateRangeFilter = filters.filterIsInstance().firstOrNull() val customerFilter = filters.filterIsInstance().firstOrNull() @@ -81,6 +91,7 @@ interface BookingsDao { startDateBefore = dateRangeFilter?.before?.epochSecond, startDateAfter = dateRangeFilter?.after?.epochSecond, customerId = customerFilter?.customerId, + order = order ) } } From c09ffa70ea00393557696e10af74325d95639216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Fri, 3 Oct 2025 01:36:24 +0300 Subject: [PATCH 04/11] Pass order through BookingsRepository methods --- .../android/ui/bookings/BookingsRepository.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt index 5f48291db65..2fc25008db8 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/BookingsRepository.kt @@ -4,6 +4,7 @@ import com.woocommerce.android.WooException import com.woocommerce.android.tools.SelectedSite import kotlinx.coroutines.flow.Flow import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption +import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsOrderOption import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsStore import org.wordpress.android.fluxc.persistence.entity.BookingEntity import javax.inject.Inject @@ -15,13 +16,15 @@ class BookingsRepository @Inject constructor( suspend fun fetchBookings( page: Int, perPage: Int, - filters: List = emptyList() + filters: List = emptyList(), + order: BookingsOrderOption ): Result { val result = bookingsStore.fetchBookings( site = selectedSite.get(), perPage = perPage, page = page, - filters = filters + filters = filters, + order = order ) return if (result.isError) { Result.failure(WooException(result.error)) @@ -32,12 +35,14 @@ class BookingsRepository @Inject constructor( fun observeBookings( limit: Int? = null, - filters: List = emptyList() + filters: List = emptyList(), + order: BookingsOrderOption ): Flow> = bookingsStore.observeBookings( site = selectedSite.get(), limit = limit, - filters = filters + filters = filters, + order = order ) } From 276c4d157857b60047f21064385a913f5af9de60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Fri, 3 Oct 2025 01:36:30 +0300 Subject: [PATCH 05/11] Manage sort state and map to BookingsOrderOption --- .../ui/bookings/list/BookingListHandler.kt | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt index 3f13af9e770..bcefb7cf631 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandler.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsFilterOption +import org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings.BookingsOrderOption import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -29,13 +30,23 @@ class BookingListHandler @Inject constructor( private val searchQuery = MutableStateFlow(null) private val filters = MutableStateFlow>(emptyList()) + private val sortBy = MutableStateFlow(BookingListSortOption.NewestToOldest) private val searchResults = MutableStateFlow(emptyList()) @OptIn(ExperimentalCoroutinesApi::class) - val bookingsFlow: Flow> = combine(searchQuery, filters, page) { query, filters, page -> + val bookingsFlow: Flow> = combine( + searchQuery, + filters, + page, + sortBy + ) { query, filters, page, sortBy -> if (query == null) { - bookingsRepository.observeBookings(limit = page * PAGE_SIZE, filters) + bookingsRepository.observeBookings( + limit = page * PAGE_SIZE, + filters = filters, + order = sortBy.toBookingsOrderOption() + ) } else { searchResults } @@ -44,7 +55,8 @@ class BookingListHandler @Inject constructor( suspend fun loadBookings( searchQuery: String? = null, forceRefresh: Boolean = false, - filters: List = emptyList() + filters: List = emptyList(), + sortBy: BookingListSortOption ): Result = mutex.withLock { // Reset pagination attributes page.value = 1 @@ -52,6 +64,7 @@ class BookingListHandler @Inject constructor( this.searchQuery.value = searchQuery this.filters.value = filters + this.sortBy.value = sortBy return@withLock if (searchQuery == null) { if (forceRefresh) { @@ -82,10 +95,12 @@ class BookingListHandler @Inject constructor( } private suspend fun fetchBookings(): Result { + val order = sortBy.value.toBookingsOrderOption() return bookingsRepository.fetchBookings( page = page.value, perPage = PAGE_SIZE, - filters = filters.value + filters = filters.value, + order = order ).onSuccess { hasMorePages -> canLoadMore.set(hasMorePages) if (hasMorePages) { @@ -94,6 +109,11 @@ class BookingListHandler @Inject constructor( }.map { } } + private fun BookingListSortOption.toBookingsOrderOption() = when (this) { + BookingListSortOption.NewestToOldest -> BookingsOrderOption.DESC + BookingListSortOption.OldestToNewest -> BookingsOrderOption.ASC + } + private suspend fun searchBookings(): Result { TODO("Not yet implemented") } From 76b0a402bb76b324c101cfa85d1e7107191931ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Fri, 3 Oct 2025 01:36:41 +0300 Subject: [PATCH 06/11] Change loading state priority in Bookings screen --- .../ui/bookings/list/BookingListScreen.kt | 19 ++++++++++--------- .../ui/bookings/list/BookingListViewModel.kt | 7 +++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt index d7e9e99648f..1fd2788b8b7 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt @@ -39,6 +39,7 @@ import com.woocommerce.android.R import com.woocommerce.android.ui.bookings.compose.BookingAttendanceStatus import com.woocommerce.android.ui.bookings.compose.BookingStatus import com.woocommerce.android.ui.bookings.compose.BookingSummary +import com.woocommerce.android.ui.bookings.compose.BookingSummaryModel import com.woocommerce.android.ui.compose.component.InfiniteListHandler import com.woocommerce.android.ui.compose.component.Toolbar import com.woocommerce.android.ui.compose.component.WCPrimaryTabRow @@ -87,14 +88,6 @@ fun BookingListScreen(state: BookingListViewState) { HorizontalDivider() } when { - state.contentState.isNotEmpty() -> { - BookingList( - state = state.contentState, - listState = lazyListState, - modifier = Modifier.fillMaxSize() - ) - } - state.contentState.loadingState == BookingListLoadingState.Loading -> { // TODO replace with shimmer CircularProgressIndicator( @@ -105,6 +98,14 @@ fun BookingListScreen(state: BookingListViewState) { ) } + state.contentState.isNotEmpty() -> { + BookingList( + state = state.contentState, + listState = lazyListState, + modifier = Modifier.fillMaxSize() + ) + } + else -> { // TODO replace with empty state Text( @@ -229,7 +230,7 @@ private fun BookingListPreview() { bookings = List(20) { BookingListItem( id = it.toLong(), - summary = com.woocommerce.android.ui.bookings.compose.BookingSummaryModel( + summary = BookingSummaryModel( date = "Aug 20, 2024", name = "Women’s Haircut", customerName = "Margarita Nikolaevna", diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt index bc825c6d135..cdb353dbe86 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt @@ -100,9 +100,11 @@ class BookingListViewModel @Inject constructor( private fun fetchBookings(initialLoadingState: BookingListLoadingState) = launch { loadingState.value = initialLoadingState + val sortOption = sortOptionsByTab.value[selectedTab.value] ?: BookingListSortOption.NewestToOldest bookingListHandler.loadBookings( forceRefresh = true, - filters = prepareFilters() + filters = prepareFilters(), + sortBy = sortOption ).onFailure { triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.bookings_fetch_error)) } @@ -141,7 +143,8 @@ class BookingListViewModel @Inject constructor( sortOptionsByTab.value = sortOptionsByTab.value.toMutableMap() .also { it[tab] = option } isSortSheetVisible.value = false - // TODO Apply the selected sorting to the data for the active tab + bookingsFetchJob?.cancel() + bookingsFetchJob = fetchBookings(BookingListLoadingState.Loading) } private fun onSortDismiss() { From a201bb2c18c3e21b3b63e87d1520303914543f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Fri, 3 Oct 2025 01:51:41 +0300 Subject: [PATCH 07/11] Fix Booking tests --- .../bookings/list/BookingListHandlerTest.kt | 55 +++++++++++++------ .../bookings/list/BookingListViewModelTest.kt | 25 +++++++-- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandlerTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandlerTest.kt index 17ce13cfbc3..96ca193d360 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandlerTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListHandlerTest.kt @@ -32,11 +32,11 @@ class BookingListHandlerTest : BaseUnitTest() { private val availablePages = 3 private val bookingsRepository: BookingsRepository = mock { val results = MutableStateFlow(emptyList()) - on { observeBookings(any(), any()) } doAnswer { invocation -> + on { observeBookings(any(), any(), any()) } doAnswer { invocation -> val limit = invocation.getArgument(0) results.map { it.take(limit) } } - onBlocking { fetchBookings(any(), any(), any()) } doAnswer InlineClassesAnswer { invocation -> + onBlocking { fetchBookings(any(), any(), any(), any()) } doAnswer InlineClassesAnswer { invocation -> val page = invocation.getArgument(0) val perPage = invocation.getArgument(1) val canLoadMore = page < availablePages @@ -54,7 +54,7 @@ class BookingListHandlerTest : BaseUnitTest() { @Test fun `given repository returns bookings, when observing bookings flow, then returns bookings`() = testBlocking { val sampleBookings = List(10) { getSampleBooking(it) } - given(bookingsRepository.observeBookings(any(), any())).willReturn(flowOf(sampleBookings)) + given(bookingsRepository.observeBookings(any(), any(), any())).willReturn(flowOf(sampleBookings)) val bookings = bookingListHandler.bookingsFlow.first() @@ -64,7 +64,11 @@ class BookingListHandlerTest : BaseUnitTest() { @Test fun `given no search query and force refresh, when loading bookings, then fetches from repository`() = testBlocking { - val result = bookingListHandler.loadBookings(searchQuery = null, forceRefresh = true) + val result = bookingListHandler.loadBookings( + searchQuery = null, + forceRefresh = true, + sortBy = BookingListSortOption.NewestToOldest + ) val bookings = bookingListHandler.bookingsFlow.first() assertThat(result.isSuccess).isTrue() @@ -75,10 +79,14 @@ class BookingListHandlerTest : BaseUnitTest() { fun `given repository fetch fails, when loading bookings with force refresh, then returns failure`() = testBlocking { val exception = Exception("Network error") - given(bookingsRepository.fetchBookings(page = any(), perPage = any(), filters = any())) + given(bookingsRepository.fetchBookings(page = any(), perPage = any(), filters = any(), order = any())) .willReturn(Result.failure(exception)) - val result = bookingListHandler.loadBookings(searchQuery = null, forceRefresh = true) + val result = bookingListHandler.loadBookings( + searchQuery = null, + forceRefresh = true, + sortBy = BookingListSortOption.NewestToOldest + ) assertThat(result.isFailure).isTrue() assertThat(result.exceptionOrNull()).isEqualTo(exception) @@ -90,12 +98,12 @@ class BookingListHandlerTest : BaseUnitTest() { val result = bookingListHandler.loadMore() assertThat(result.isSuccess).isTrue() - verify(bookingsRepository, never()).fetchBookings(any(), any(), any()) + verify(bookingsRepository, never()).fetchBookings(any(), any(), any(), any()) } @Test fun `when load more is called and can load more is true, then fetches next page`() = testBlocking { - bookingListHandler.loadBookings(forceRefresh = true) + bookingListHandler.loadBookings(forceRefresh = true, sortBy = BookingListSortOption.NewestToOldest) val result = bookingListHandler.loadMore() val bookings = bookingListHandler.bookingsFlow.first() @@ -106,7 +114,7 @@ class BookingListHandlerTest : BaseUnitTest() { @Test fun `when last page is reached, then can load more becomes false`() = testBlocking { - bookingListHandler.loadBookings(forceRefresh = true) + bookingListHandler.loadBookings(forceRefresh = true, sortBy = BookingListSortOption.NewestToOldest) var result: Result? = null repeat(availablePages - 1) { @@ -117,18 +125,19 @@ class BookingListHandlerTest : BaseUnitTest() { verify(bookingsRepository, never()).fetchBookings( page = intThat { it > availablePages }, perPage = any(), - filters = any() + filters = any(), + order = any() ) } @Test fun `when load bookings is called, then pagination resets`() = testBlocking { // First load and load more to advance page - bookingListHandler.loadBookings(forceRefresh = true) + bookingListHandler.loadBookings(forceRefresh = true, sortBy = BookingListSortOption.NewestToOldest) bookingListHandler.loadMore() // Load bookings again - should reset to page 1 - bookingListHandler.loadBookings(forceRefresh = true) + bookingListHandler.loadBookings(forceRefresh = true, sortBy = BookingListSortOption.NewestToOldest) val bookings = bookingListHandler.bookingsFlow.first() assertThat(bookings).hasSize(BookingListHandler.PAGE_SIZE) @@ -136,7 +145,7 @@ class BookingListHandlerTest : BaseUnitTest() { @Test fun `when bookings flow is observed with pagination, then limit increases correctly`() = testBlocking { - bookingListHandler.loadBookings(forceRefresh = true) + bookingListHandler.loadBookings(forceRefresh = true, sortBy = BookingListSortOption.NewestToOldest) val initialBookings = bookingListHandler.bookingsFlow.first() assertThat(initialBookings).hasSize(BookingListHandler.PAGE_SIZE) @@ -145,15 +154,29 @@ class BookingListHandlerTest : BaseUnitTest() { val moreBookings = bookingListHandler.bookingsFlow.first() @Suppress("UnusedFlow") - verify(bookingsRepository).observeBookings(limit = eq(2 * BookingListHandler.PAGE_SIZE), filters = any()) + verify(bookingsRepository).observeBookings( + limit = eq(2 * BookingListHandler.PAGE_SIZE), + filters = any(), + order = any() + ) assertThat(moreBookings).hasSize(2 * BookingListHandler.PAGE_SIZE) } @Test fun `when concurrent load operations occur, then operations are synchronized`() = testBlocking { // Launch multiple concurrent load operations - val job1 = launch { bookingListHandler.loadBookings(forceRefresh = true) } - val job2 = launch { bookingListHandler.loadBookings(forceRefresh = true) } + val job1 = launch { + bookingListHandler.loadBookings( + forceRefresh = true, + sortBy = BookingListSortOption.NewestToOldest + ) + } + val job2 = launch { + bookingListHandler.loadBookings( + forceRefresh = true, + sortBy = BookingListSortOption.NewestToOldest + ) + } val job3 = launch { bookingListHandler.loadMore() } job1.join() diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt index ffffdcca2a2..7d85300446c 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt @@ -35,7 +35,8 @@ class BookingListViewModelTest : BaseUnitTest() { loadBookings( searchQuery = anyOrNull(), forceRefresh = any(), - filters = any() + filters = any(), + sortBy = any() ) } doReturn Result.success(Unit) onBlocking { loadMore() } doReturn Result.success(Unit) @@ -68,7 +69,12 @@ class BookingListViewModelTest : BaseUnitTest() { advanceUntilIdle() // THEN - verify(bookingListHandler).loadBookings(searchQuery = eq(null), forceRefresh = eq(true), filters = any()) + verify(bookingListHandler).loadBookings( + searchQuery = eq(null), + forceRefresh = eq(true), + filters = any(), + sortBy = any() + ) val state = viewModel.state.getOrAwaitValue().contentState assertThat(state.bookings).hasSize(1) @@ -108,7 +114,8 @@ class BookingListViewModelTest : BaseUnitTest() { verify(bookingListHandler, times(2)).loadBookings( searchQuery = eq(null), forceRefresh = eq(true), - filters = any() + filters = any(), + sortBy = any() ) } @@ -148,7 +155,14 @@ class BookingListViewModelTest : BaseUnitTest() { fun `when booking handler fails to load, then show error snackbar`() = testBlocking { // GIVEN setup { - whenever(bookingListHandler.loadBookings(searchQuery = anyOrNull(), forceRefresh = any(), filters = any())) + whenever( + bookingListHandler.loadBookings( + searchQuery = anyOrNull(), + forceRefresh = any(), + filters = any(), + sortBy = any() + ) + ) .thenReturn(Result.failure(Exception("Network error"))) } @@ -216,7 +230,8 @@ class BookingListViewModelTest : BaseUnitTest() { BookingListTab.Upcoming.asDateRangeFilter() } ) - ) + ), + sortBy = any() ) } From 885de6659eb36fa6249eada97ecb515c1ec9dd9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Sat, 4 Oct 2025 16:10:11 +0300 Subject: [PATCH 08/11] Refactor bookings list fetching on sort Refactor the bookings list fetching logic to be triggered by changes in the sort option flow. This removes the explicit fetch call in the `onSortOptionSelected` function and uses a `Refreshing` state when sorting. --- .../ui/bookings/list/BookingListViewModel.kt | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt index 4ee2ed16e44..dd71aa2b089 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch @@ -119,15 +120,24 @@ class BookingListViewModel @Inject constructor( .debounce { if (it.isNullOrEmpty()) 0L else AppConstants.SEARCH_TYPING_DELAY_MS } + val sortFlow = selectedTab.flatMapLatest { tab -> + sortOptionsByTab.map { it[tab] ?: BookingListSortOption.NewestToOldest } + .drop(1) // Skip the initial value to avoid double fetch on init + } - merge(selectedTab, queryFlow) - .collectLatest { - // Cancel any ongoing fetch or load more operations - bookingsFetchJob?.cancel() - bookingsLoadMoreJob?.cancel() - - bookingsFetchJob = fetchBookings(BookingListLoadingState.Loading) - } + merge(selectedTab, queryFlow, sortFlow).collectLatest { + // Cancel any ongoing fetch or load more operations + bookingsFetchJob?.cancel() + bookingsLoadMoreJob?.cancel() + + bookingsFetchJob = fetchBookings( + initialLoadingState = if (it is BookingListSortOption) { + BookingListLoadingState.Refreshing + } else { + BookingListLoadingState.Loading + } + ) + } } } @@ -176,8 +186,6 @@ class BookingListViewModel @Inject constructor( sortOptionsByTab.value = sortOptionsByTab.value.toMutableMap() .also { it[tab] = option } isSortSheetVisible.value = false - bookingsFetchJob?.cancel() - bookingsFetchJob = fetchBookings(BookingListLoadingState.Loading) } private fun onSortDismiss() { From 4c7d1dea1f096c8e2e752f324228c8f2af8f552f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Sat, 4 Oct 2025 16:32:59 +0300 Subject: [PATCH 09/11] =?UTF-8?q?Make=20the=20Bookings=20=E2=80=9CSort=20b?= =?UTF-8?q?y=E2=80=9D=20state=20global?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/bookings/list/BookingListViewModel.kt | 26 +++---------- .../bookings/list/BookingListViewModelTest.kt | 37 ------------------- 2 files changed, 6 insertions(+), 57 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt index dd71aa2b089..65c2c1a9d7e 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModel.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch @@ -39,13 +38,7 @@ class BookingListViewModel @Inject constructor( key = "searchQuery" ) - private val sortOptionsByTab = MutableStateFlow( - mapOf( - BookingListTab.Today to BookingListSortOption.NewestToOldest, - BookingListTab.Upcoming to BookingListSortOption.NewestToOldest, - BookingListTab.All to BookingListSortOption.NewestToOldest, - ) - ) + private val sortOption = savedStateHandle.getStateFlow(viewModelScope, BookingListSortOption.NewestToOldest) private val isSortSheetVisible = MutableStateFlow(false) @@ -76,11 +69,10 @@ class BookingListViewModel @Inject constructor( val state = combine( contentState, selectedTab, - sortOptionsByTab, + sortOption, isSortSheetVisible, searchState - ) { contentState, selectedTab, sortOptionsByTab, sheetVisible, searchState -> - val sortOption = sortOptionsByTab[selectedTab] ?: BookingListSortOption.NewestToOldest + ) { contentState, selectedTab, sortOption, sheetVisible, searchState -> BookingListViewState( contentState = contentState, tabState = BookingListTabState( @@ -120,10 +112,7 @@ class BookingListViewModel @Inject constructor( .debounce { if (it.isNullOrEmpty()) 0L else AppConstants.SEARCH_TYPING_DELAY_MS } - val sortFlow = selectedTab.flatMapLatest { tab -> - sortOptionsByTab.map { it[tab] ?: BookingListSortOption.NewestToOldest } - .drop(1) // Skip the initial value to avoid double fetch on init - } + val sortFlow = sortOption.drop(1) // Skip the initial value to avoid double fetch on init merge(selectedTab, queryFlow, sortFlow).collectLatest { // Cancel any ongoing fetch or load more operations @@ -143,11 +132,10 @@ class BookingListViewModel @Inject constructor( private fun fetchBookings(initialLoadingState: BookingListLoadingState) = launch { loadingState.value = initialLoadingState - val sortOption = sortOptionsByTab.value[selectedTab.value] ?: BookingListSortOption.NewestToOldest bookingListHandler.loadBookings( searchQuery = searchQuery.value, filters = prepareFilters(), - sortBy = sortOption + sortBy = sortOption.value ).onFailure { triggerEvent(MultiLiveEvent.Event.ShowSnackbar(R.string.bookings_fetch_error)) } @@ -182,9 +170,7 @@ class BookingListViewModel @Inject constructor( } private fun onSortOptionSelected(option: BookingListSortOption) { - val tab = selectedTab.value - sortOptionsByTab.value = sortOptionsByTab.value.toMutableMap() - .also { it[tab] = option } + sortOption.value = option isSortSheetVisible.value = false } diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt index e2f7f05d957..9f5195be0af 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/bookings/list/BookingListViewModelTest.kt @@ -234,43 +234,6 @@ class BookingListViewModelTest : BaseUnitTest() { assertThat(withSheet.sortBottomSheetState).isNotNull() } - @Test - fun `when selecting sort in one tab, then other tabs keep their own selection`() = testBlocking { - // GIVEN - setup() - - // Open sheet on Today and select OldestToNewest - val s1 = viewModel.state.getOrAwaitValue() - s1.controlsState.onSortClick() - val sheet1 = viewModel.state.getOrAwaitValue().sortBottomSheetState!! - sheet1.onSelect(BookingListSortOption.OldestToNewest) - - // Sheet should be hidden after selection - val afterSelectToday = viewModel.state.getOrAwaitValue() - assertThat(afterSelectToday.sortBottomSheetState).isNull() - - // Re-open and ensure Today remembers OldestToNewest - afterSelectToday.controlsState.onSortClick() - val sheetTodayAgain = viewModel.state.getOrAwaitValue().sortBottomSheetState!! - assertThat(sheetTodayAgain.selectedOption).isEqualTo(BookingListSortOption.OldestToNewest) - sheetTodayAgain.onDismiss() - - // Switch to Upcoming and verify default is NewestToOldest - val stateAfterDismiss = viewModel.state.getOrAwaitValue() - stateAfterDismiss.tabState.onTabChanged(BookingListTab.Upcoming) - val upcomingState = viewModel.state.getOrAwaitValue() - upcomingState.controlsState.onSortClick() - val sheetUpcoming = viewModel.state.getOrAwaitValue().sortBottomSheetState!! - assertThat(sheetUpcoming.selectedOption).isEqualTo(BookingListSortOption.NewestToOldest) - - // Switch back to Today and ensure it still holds its own selection (OldestToNewest) - upcomingState.tabState.onTabChanged(BookingListTab.Today) - val backToToday = viewModel.state.getOrAwaitValue() - backToToday.controlsState.onSortClick() - val sheetBackToToday = viewModel.state.getOrAwaitValue().sortBottomSheetState!! - assertThat(sheetBackToToday.selectedOption).isEqualTo(BookingListSortOption.OldestToNewest) - } - @Test fun `when search query is changed, then state is updated`() = testBlocking { // GIVEN From d3ff7016e482b82655b7f3bd5fd098ca5a6a1d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Sat, 4 Oct 2025 17:06:55 +0300 Subject: [PATCH 10/11] Reorder when-clause for loading state in booking list --- .../ui/bookings/list/BookingListScreen.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt index 004e7a276c0..18eef87c087 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/bookings/list/BookingListScreen.kt @@ -104,6 +104,14 @@ fun BookingListScreen(state: BookingListViewState) { HorizontalDivider(thickness = 0.5.dp) when { + state.contentState.isNotEmpty() -> { + BookingList( + state = state.contentState, + listState = lazyListState, + modifier = Modifier.fillMaxSize() + ) + } + state.contentState.loadingState == BookingListLoadingState.Loading -> { // TODO replace with shimmer CircularProgressIndicator( @@ -114,14 +122,6 @@ fun BookingListScreen(state: BookingListViewState) { ) } - state.contentState.isNotEmpty() -> { - BookingList( - state = state.contentState, - listState = lazyListState, - modifier = Modifier.fillMaxSize() - ) - } - else -> { // TODO replace with empty state Text( From 3f2b31a627fe2538703820924c9e63a092551e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Tue, 7 Oct 2025 16:32:00 +0300 Subject: [PATCH 11/11] Remove unnecessary comment from BookingsOrderOption --- .../fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt index 8b7137c91dd..575bc88f3a6 100644 --- a/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt +++ b/libs/fluxc-plugin/src/main/kotlin/org/wordpress/android/fluxc/network/rest/wpcom/wc/bookings/BookingsOrderOption.kt @@ -1,7 +1,7 @@ package org.wordpress.android.fluxc.network.rest.wpcom.wc.bookings /** - * Represents the sort order for bookings. Only two values are allowed by the API: "asc" and "desc". + * Represents the sort order for bookings. * * Use [value] when building query parameters. */