- 
                Notifications
    
You must be signed in to change notification settings  - Fork 135
 
[CIAB] BookingListViewModel refactoring and some fixes #14780
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
Changes from all commits
c8be29a
              5b7c598
              7c0fb08
              5982f5d
              6fe7f69
              090a148
              edcbf72
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| 
          
            
          
           | 
    @@ -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 | ||
| 
     | 
||
| 
        
          
        
         | 
    @@ -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) | ||
| 
     | 
||
| 
        
          
        
         | 
    @@ -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 | ||
| ) | ||
| 
          
            
          
           | 
    @@ -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
    
   
  
    
 | 
||
| 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)) | ||
| } | ||
| 
          
            
          
           | 
    @@ -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 | ||||
|---|---|---|---|---|---|---|
| 
          
            
          
           | 
    @@ -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
    
   
  There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 
  | 
||||||
| 
     | 
||||||
| val lastValue by rememberUpdatedState(value) | ||||||
| 
     | 
||||||
| BasicTextField( | ||||||
| value = textFieldValueState, | ||||||
| value = textFieldValue, | ||||||
| onValueChange = { | ||||||
| textFieldValueState = it | ||||||
| if (it.text != lastValue) { | ||||||
| 
          
            
          
           | 
    ||||||
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.
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:
Screen_recording_20251017_181115.mp4
Screen_recording_20251017_181007.mp4