Skip to content
This repository has been archived by the owner on Jan 5, 2023. It is now read-only.

Commit

Permalink
Migrate Search and Filters to Flow
Browse files Browse the repository at this point in the history
Fixes: b/186827056 and b/186632979
Change-Id: Ie7952edb0a8d8043b4495a434ea166fe0d7991a8
  • Loading branch information
Manuel Vivo committed May 10, 2021
1 parent 507726f commit 788454e
Show file tree
Hide file tree
Showing 13 changed files with 238 additions and 216 deletions.
Expand Up @@ -28,19 +28,20 @@ import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePaddingRelative
import androidx.databinding.ObservableFloat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.OnScrollListener
import com.google.android.flexbox.FlexboxItemDecoration
import com.google.samples.apps.iosched.R
import com.google.samples.apps.iosched.databinding.FragmentFiltersBinding
import com.google.samples.apps.iosched.util.doOnApplyWindowInsets
import com.google.samples.apps.iosched.util.launchAndRepeatWithViewLifecycle
import com.google.samples.apps.iosched.util.slideOffsetToAlpha
import com.google.samples.apps.iosched.widget.BottomSheetBehavior
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.BottomSheetCallback
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_COLLAPSED
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_EXPANDED
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_HIDDEN
import kotlinx.coroutines.flow.collect

/**
* Fragment that shows the list of filters for the Schedule
Expand Down Expand Up @@ -115,13 +116,6 @@ abstract class FiltersFragment : Fragment() {
behavior = BottomSheetBehavior.from(binding.filterSheet)

filterAdapter = SelectableFilterChipAdapter(viewModel)
viewModel.filterChips.observe(
viewLifecycleOwner,
Observer {
filterAdapter.submitFilterList(it)
}
)

binding.recyclerviewFilters.apply {
adapter = filterAdapter
setHasFixedSize(true)
Expand Down Expand Up @@ -182,6 +176,16 @@ abstract class FiltersFragment : Fragment() {
updateBackPressedCallbackEnabled(behavior.state)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

launchAndRepeatWithViewLifecycle {
viewModel.filterChips.collect {
filterAdapter.submitFilterList(it)
}
}
}

private fun updateFilterContentsAlpha(slideOffset: Float) {
// Since the content is visible behind the navigation bar, apply a short alpha transition.
contentAlpha.set(
Expand Down
Expand Up @@ -16,28 +16,32 @@

package com.google.samples.apps.iosched.ui.filters

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.google.samples.apps.iosched.model.filters.Filter
import com.google.samples.apps.iosched.util.compatRemoveIf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

/**
* Interface to add filters functionality to a screen through a ViewModel.
*/
interface FiltersViewModelDelegate {
/** The full list of filter chips. */
val filterChips: LiveData<List<FilterChip>>
val filterChips: Flow<List<FilterChip>>
/** The list of selected filters. */
val selectedFilters: LiveData<List<Filter>>
val selectedFilters: StateFlow<List<Filter>>
/** The list of selected filter chips. */
val selectedFilterChips: LiveData<List<FilterChip>>
val selectedFilterChips: StateFlow<List<FilterChip>>
/** True if there are any selected filters. */
val hasAnyFilters: LiveData<Boolean>
val hasAnyFilters: StateFlow<Boolean>
/** Number of results from applying filters. Can be set by implementers. */
val resultCount: MutableLiveData<Int>
val resultCount: MutableStateFlow<Int>
/** Whether to show the result count instead of the "Filters" header. */
val showResultCount: LiveData<Boolean>
val showResultCount: StateFlow<Boolean>

/** Set the list of filters. */
fun setSupportedFilters(filters: List<Filter>)
Expand All @@ -49,68 +53,80 @@ interface FiltersViewModelDelegate {
fun clearFilters()
}

class FiltersViewModelDelegateImpl : FiltersViewModelDelegate {
class FiltersViewModelDelegateImpl(
externalScope: CoroutineScope
) : FiltersViewModelDelegate {

override val filterChips = MutableLiveData<List<FilterChip>>(emptyList())
private val _filterChips = MutableStateFlow<List<FilterChip>>(emptyList())
override val filterChips: Flow<List<FilterChip>> = _filterChips

override val selectedFilters = MutableLiveData<List<Filter>>(emptyList())
private val _selectedFilters = MutableStateFlow<List<Filter>>(emptyList())
override val selectedFilters: StateFlow<List<Filter>> = _selectedFilters

override val selectedFilterChips = MutableLiveData<List<FilterChip>>(emptyList())
private val _selectedFilterChips = MutableStateFlow<List<FilterChip>>(emptyList())
override val selectedFilterChips: StateFlow<List<FilterChip>> = _selectedFilterChips

override val hasAnyFilters = selectedFilterChips.map { it.isNotEmpty() }
override val hasAnyFilters = selectedFilterChips
.map { it.isNotEmpty() }
.stateIn(externalScope, SharingStarted.Lazily, false)

override val resultCount = MutableLiveData(0)
override val resultCount = MutableStateFlow(0)

// Default behavior: show count when there are active filters.
override val showResultCount = hasAnyFilters

// State for internal logic
private var _filters = mutableListOf<Filter>()
private val _selectedFilters = mutableSetOf<Filter>()
private var _filterChips = mutableListOf<FilterChip>()
private var _selectedFilterChips = mutableListOf<FilterChip>()
private val _selectedFiltersList = mutableSetOf<Filter>()
private var _filterChipsList = mutableListOf<FilterChip>()
private var _selectedFilterChipsList = mutableListOf<FilterChip>()

override fun setSupportedFilters(filters: List<Filter>) {
// Remove orphaned filters
val selectedChanged = _selectedFilters.compatRemoveIf { it !in filters }
val selectedChanged = _selectedFiltersList.compatRemoveIf { it !in filters }
_filters = filters.toMutableList()
_filterChips = _filters.mapTo(mutableListOf()) {
it.asChip(it in _selectedFilters)
_filterChipsList = _filters.mapTo(mutableListOf()) {
it.asChip(it in _selectedFiltersList)
}

if (selectedChanged) {
_selectedFilterChips = _filterChips.filterTo(mutableListOf()) { it.isSelected }
_selectedFilterChipsList = _filterChipsList.filterTo(mutableListOf()) { it.isSelected }
}
publish(selectedChanged)
}

private fun publish(selectedChanged: Boolean) {
filterChips.value = _filterChips
_filterChips.value = _filterChipsList
if (selectedChanged) {
selectedFilters.value = _selectedFilters.toList()
selectedFilterChips.value = _selectedFilterChips
_selectedFilters.value = _selectedFiltersList.toList()
_selectedFilterChips.value = _selectedFilterChipsList
}
}

override fun toggleFilter(filter: Filter, enabled: Boolean) {
if (filter !in _filters) {
throw IllegalArgumentException("Unsupported filter: $filter")
}
val changed = if (enabled) _selectedFilters.add(filter) else _selectedFilters.remove(filter)
val changed = if (enabled) {
_selectedFiltersList.add(filter)
} else {
_selectedFiltersList.remove(filter)
}
if (changed) {
_selectedFilterChips = _selectedFilters.mapTo(mutableListOf()) { it.asChip(true) }
val index = _filterChips.indexOfFirst { it.filter == filter }
_filterChips[index] = filter.asChip(enabled)
_selectedFilterChipsList =
_selectedFiltersList.mapTo(mutableListOf()) { it.asChip(true) }
val index = _filterChipsList.indexOfFirst { it.filter == filter }
_filterChipsList[index] = filter.asChip(enabled)

publish(true)
}
}

override fun clearFilters() {
if (_selectedFilters.isNotEmpty()) {
_selectedFilters.clear()
_selectedFilterChips.clear()
_filterChips = _filterChips.mapTo(mutableListOf()) {
if (_selectedFiltersList.isNotEmpty()) {
_selectedFiltersList.clear()
_selectedFilterChipsList.clear()
_filterChipsList = _filterChipsList.mapTo(mutableListOf()) {
if (it.isSelected) it.copy(isSelected = false) else it
}

Expand Down
Expand Up @@ -16,15 +16,19 @@

package com.google.samples.apps.iosched.ui.filters

import com.google.samples.apps.iosched.shared.di.ApplicationScope
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope

@InstallIn(SingletonComponent::class)
@Module
class FiltersViewModelDelegateModule {

@Provides
fun provideFiltersViewModelDelegate(): FiltersViewModelDelegate = FiltersViewModelDelegateImpl()
fun provideFiltersViewModelDelegate(
@ApplicationScope applicationScope: CoroutineScope
): FiltersViewModelDelegate = FiltersViewModelDelegateImpl(applicationScope)
}
Expand Up @@ -17,7 +17,6 @@
package com.google.samples.apps.iosched.ui.schedule

import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.iosched.R
import com.google.samples.apps.iosched.model.ConferenceDay
Expand All @@ -37,6 +36,7 @@ import com.google.samples.apps.iosched.shared.domain.users.StarEventAndNotifyUse
import com.google.samples.apps.iosched.shared.domain.users.StarEventParameter
import com.google.samples.apps.iosched.shared.fcm.TopicSubscriber
import com.google.samples.apps.iosched.shared.result.Result
import com.google.samples.apps.iosched.shared.result.Result.Error
import com.google.samples.apps.iosched.shared.result.Result.Success
import com.google.samples.apps.iosched.shared.result.data
import com.google.samples.apps.iosched.shared.result.successOr
Expand Down Expand Up @@ -96,19 +96,15 @@ class ScheduleViewModel @Inject constructor(
SignInViewModelDelegate by signInViewModelDelegate {

// Exposed to the view as a StateFlow but it's a one-shot operation.
// TODO: Rename with timeZoneId when ScheduleViewModel is migrated
val timeZoneIdFlow = flow<ZoneId> {
val timeZoneId = flow<ZoneId> {
if (getTimeZoneUseCase(Unit).successOr(true)) {
emit(TimeUtils.CONFERENCE_TIMEZONE)
} else {
emit(ZoneId.systemDefault())
}
}.stateIn(viewModelScope, Lazily, TimeUtils.CONFERENCE_TIMEZONE)

// TODO: Replace with timeZoneIdFlow when SearchViewModel is migrated
val timeZoneId = timeZoneIdFlow.asLiveData()

val isConferenceTimeZone: StateFlow<Boolean> = timeZoneIdFlow.mapLatest { zoneId ->
val isConferenceTimeZone: StateFlow<Boolean> = timeZoneId.mapLatest { zoneId ->
TimeUtils.isConferenceTimeZone(zoneId)
}.stateIn(viewModelScope, Lazily, true)

Expand Down Expand Up @@ -145,11 +141,11 @@ class ScheduleViewModel @Inject constructor(
}
.onEach {
// Side effect: show error messages coming from LoadScheduleUserSessionsUseCase
if (it is Result.Error) {
if (it is Error) {
_errorMessage.tryOffer(it.exception.message ?: "Error")
}
// Side effect: show snackbar if the result contains a message
if (it is Result.Success) {
if (it is Success) {
it.data.userMessage?.type?.stringRes()?.let { messageId ->
// There is a message to display:
snackbarMessageManager.addMessage(
Expand All @@ -171,7 +167,7 @@ class ScheduleViewModel @Inject constructor(

// Expose new UI data when loadSessionsResult changes
val scheduleUiData: StateFlow<ScheduleUiData> =
loadSessionsResult.combineTransform(timeZoneIdFlow) { sessions, timeZone ->
loadSessionsResult.combineTransform(timeZoneId) { sessions, timeZone ->
sessions.data?.let { data ->
dayIndexer = data.dayIndexer
emit(
Expand Down Expand Up @@ -301,7 +297,7 @@ class ScheduleViewModel @Inject constructor(
)
)
// Show an error message if a star request fails
if (result is Result.Error) {
if (result is Error) {
snackbarMessageManager.addMessage(SnackbarMessage(R.string.event_star_error))
}
}
Expand Down
Expand Up @@ -26,20 +26,21 @@ import android.view.inputmethod.InputMethodManager
import android.widget.SearchView
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import com.google.samples.apps.iosched.R
import com.google.samples.apps.iosched.databinding.FragmentSearchBinding
import com.google.samples.apps.iosched.shared.analytics.AnalyticsHelper
import com.google.samples.apps.iosched.shared.result.EventObserver
import com.google.samples.apps.iosched.ui.MainNavigationFragment
import com.google.samples.apps.iosched.ui.search.SearchFragmentDirections.Companion.toSessionDetail
import com.google.samples.apps.iosched.ui.search.SearchFragmentDirections.Companion.toSpeakerDetail
import com.google.samples.apps.iosched.ui.sessioncommon.SessionsAdapter
import com.google.samples.apps.iosched.util.doOnApplyWindowInsets
import com.google.samples.apps.iosched.util.launchAndRepeatWithViewLifecycle
import com.google.samples.apps.iosched.util.openWebsiteUrl
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Named

Expand All @@ -63,7 +64,7 @@ class SearchFragment : MainNavigationFragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
): View {
val themedInflater =
inflater.cloneInContext(ContextThemeWrapper(requireActivity(), R.style.AppTheme_Detail))
binding = FragmentSearchBinding.inflate(themedInflater, container, false).apply {
Expand All @@ -74,31 +75,6 @@ class SearchFragment : MainNavigationFragment() {

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)

viewModel.searchResults.observe(
viewLifecycleOwner,
Observer {
sessionsAdapter.submitList(it)
}
)
viewModel.navigateToSessionAction.observe(
viewLifecycleOwner,
EventObserver { sessionId ->
findNavController().navigate(toSessionDetail(sessionId))
}
)
viewModel.navigateToSpeakerAction.observe(
viewLifecycleOwner,
EventObserver { speakerId ->
findNavController().navigate(toSpeakerDetail(speakerId))
}
)
viewModel.navigateToCodelabAction.observe(
viewLifecycleOwner,
EventObserver { url ->
openWebsiteUrl(requireActivity(), url)
}
)
analyticsHelper.sendScreenView("Search", requireActivity())
}

Expand Down Expand Up @@ -154,6 +130,29 @@ class SearchFragment : MainNavigationFragment() {
}
}

launchAndRepeatWithViewLifecycle {
launch {
viewModel.searchResults.collect {
sessionsAdapter.submitList(it)
}
}
launch {
viewModel.navigationActions.collect { event ->
when (event) {
is SearchNavigationAction.OpenSession -> {
findNavController().navigate(toSessionDetail(event.sessionId))
}
is SearchNavigationAction.OpenSpeaker -> {
findNavController().navigate(toSpeakerDetail(event.speakerId))
}
is SearchNavigationAction.OpenCodelab -> {
openWebsiteUrl(requireActivity(), event.codelabUrl)
}
}
}
}
}

if (savedInstanceState == null) {
// On first entry, show the filters.
findFiltersFragment().showFiltersSheet()
Expand Down

0 comments on commit 788454e

Please sign in to comment.