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

Commit 788454e

Browse files
author
Manuel Vivo
committed
Migrate Search and Filters to Flow
Fixes: b/186827056 and b/186632979 Change-Id: Ie7952edb0a8d8043b4495a434ea166fe0d7991a8
1 parent 507726f commit 788454e

File tree

13 files changed

+238
-216
lines changed

13 files changed

+238
-216
lines changed

mobile/src/main/java/com/google/samples/apps/iosched/ui/filters/FiltersFragment.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,20 @@ import androidx.core.view.updateLayoutParams
2828
import androidx.core.view.updatePaddingRelative
2929
import androidx.databinding.ObservableFloat
3030
import androidx.fragment.app.Fragment
31-
import androidx.lifecycle.Observer
3231
import androidx.recyclerview.widget.RecyclerView
3332
import androidx.recyclerview.widget.RecyclerView.OnScrollListener
3433
import com.google.android.flexbox.FlexboxItemDecoration
3534
import com.google.samples.apps.iosched.R
3635
import com.google.samples.apps.iosched.databinding.FragmentFiltersBinding
3736
import com.google.samples.apps.iosched.util.doOnApplyWindowInsets
37+
import com.google.samples.apps.iosched.util.launchAndRepeatWithViewLifecycle
3838
import com.google.samples.apps.iosched.util.slideOffsetToAlpha
3939
import com.google.samples.apps.iosched.widget.BottomSheetBehavior
4040
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.BottomSheetCallback
4141
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_COLLAPSED
4242
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_EXPANDED
4343
import com.google.samples.apps.iosched.widget.BottomSheetBehavior.Companion.STATE_HIDDEN
44+
import kotlinx.coroutines.flow.collect
4445

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

117118
filterAdapter = SelectableFilterChipAdapter(viewModel)
118-
viewModel.filterChips.observe(
119-
viewLifecycleOwner,
120-
Observer {
121-
filterAdapter.submitFilterList(it)
122-
}
123-
)
124-
125119
binding.recyclerviewFilters.apply {
126120
adapter = filterAdapter
127121
setHasFixedSize(true)
@@ -182,6 +176,16 @@ abstract class FiltersFragment : Fragment() {
182176
updateBackPressedCallbackEnabled(behavior.state)
183177
}
184178

179+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
180+
super.onViewCreated(view, savedInstanceState)
181+
182+
launchAndRepeatWithViewLifecycle {
183+
viewModel.filterChips.collect {
184+
filterAdapter.submitFilterList(it)
185+
}
186+
}
187+
}
188+
185189
private fun updateFilterContentsAlpha(slideOffset: Float) {
186190
// Since the content is visible behind the navigation bar, apply a short alpha transition.
187191
contentAlpha.set(

mobile/src/main/java/com/google/samples/apps/iosched/ui/filters/FiltersViewModel.kt

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,32 @@
1616

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

19-
import androidx.lifecycle.LiveData
20-
import androidx.lifecycle.MutableLiveData
21-
import androidx.lifecycle.map
2219
import com.google.samples.apps.iosched.model.filters.Filter
2320
import com.google.samples.apps.iosched.util.compatRemoveIf
21+
import kotlinx.coroutines.CoroutineScope
22+
import kotlinx.coroutines.flow.Flow
23+
import kotlinx.coroutines.flow.MutableStateFlow
24+
import kotlinx.coroutines.flow.SharingStarted
25+
import kotlinx.coroutines.flow.StateFlow
26+
import kotlinx.coroutines.flow.map
27+
import kotlinx.coroutines.flow.stateIn
2428

2529
/**
2630
* Interface to add filters functionality to a screen through a ViewModel.
2731
*/
2832
interface FiltersViewModelDelegate {
2933
/** The full list of filter chips. */
30-
val filterChips: LiveData<List<FilterChip>>
34+
val filterChips: Flow<List<FilterChip>>
3135
/** The list of selected filters. */
32-
val selectedFilters: LiveData<List<Filter>>
36+
val selectedFilters: StateFlow<List<Filter>>
3337
/** The list of selected filter chips. */
34-
val selectedFilterChips: LiveData<List<FilterChip>>
38+
val selectedFilterChips: StateFlow<List<FilterChip>>
3539
/** True if there are any selected filters. */
36-
val hasAnyFilters: LiveData<Boolean>
40+
val hasAnyFilters: StateFlow<Boolean>
3741
/** Number of results from applying filters. Can be set by implementers. */
38-
val resultCount: MutableLiveData<Int>
42+
val resultCount: MutableStateFlow<Int>
3943
/** Whether to show the result count instead of the "Filters" header. */
40-
val showResultCount: LiveData<Boolean>
44+
val showResultCount: StateFlow<Boolean>
4145

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

52-
class FiltersViewModelDelegateImpl : FiltersViewModelDelegate {
56+
class FiltersViewModelDelegateImpl(
57+
externalScope: CoroutineScope
58+
) : FiltersViewModelDelegate {
5359

54-
override val filterChips = MutableLiveData<List<FilterChip>>(emptyList())
60+
private val _filterChips = MutableStateFlow<List<FilterChip>>(emptyList())
61+
override val filterChips: Flow<List<FilterChip>> = _filterChips
5562

56-
override val selectedFilters = MutableLiveData<List<Filter>>(emptyList())
63+
private val _selectedFilters = MutableStateFlow<List<Filter>>(emptyList())
64+
override val selectedFilters: StateFlow<List<Filter>> = _selectedFilters
5765

58-
override val selectedFilterChips = MutableLiveData<List<FilterChip>>(emptyList())
66+
private val _selectedFilterChips = MutableStateFlow<List<FilterChip>>(emptyList())
67+
override val selectedFilterChips: StateFlow<List<FilterChip>> = _selectedFilterChips
5968

60-
override val hasAnyFilters = selectedFilterChips.map { it.isNotEmpty() }
69+
override val hasAnyFilters = selectedFilterChips
70+
.map { it.isNotEmpty() }
71+
.stateIn(externalScope, SharingStarted.Lazily, false)
6172

62-
override val resultCount = MutableLiveData(0)
73+
override val resultCount = MutableStateFlow(0)
6374

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

6778
// State for internal logic
6879
private var _filters = mutableListOf<Filter>()
69-
private val _selectedFilters = mutableSetOf<Filter>()
70-
private var _filterChips = mutableListOf<FilterChip>()
71-
private var _selectedFilterChips = mutableListOf<FilterChip>()
80+
private val _selectedFiltersList = mutableSetOf<Filter>()
81+
private var _filterChipsList = mutableListOf<FilterChip>()
82+
private var _selectedFilterChipsList = mutableListOf<FilterChip>()
7283

7384
override fun setSupportedFilters(filters: List<Filter>) {
7485
// Remove orphaned filters
75-
val selectedChanged = _selectedFilters.compatRemoveIf { it !in filters }
86+
val selectedChanged = _selectedFiltersList.compatRemoveIf { it !in filters }
7687
_filters = filters.toMutableList()
77-
_filterChips = _filters.mapTo(mutableListOf()) {
78-
it.asChip(it in _selectedFilters)
88+
_filterChipsList = _filters.mapTo(mutableListOf()) {
89+
it.asChip(it in _selectedFiltersList)
7990
}
8091

8192
if (selectedChanged) {
82-
_selectedFilterChips = _filterChips.filterTo(mutableListOf()) { it.isSelected }
93+
_selectedFilterChipsList = _filterChipsList.filterTo(mutableListOf()) { it.isSelected }
8394
}
8495
publish(selectedChanged)
8596
}
8697

8798
private fun publish(selectedChanged: Boolean) {
88-
filterChips.value = _filterChips
99+
_filterChips.value = _filterChipsList
89100
if (selectedChanged) {
90-
selectedFilters.value = _selectedFilters.toList()
91-
selectedFilterChips.value = _selectedFilterChips
101+
_selectedFilters.value = _selectedFiltersList.toList()
102+
_selectedFilterChips.value = _selectedFilterChipsList
92103
}
93104
}
94105

95106
override fun toggleFilter(filter: Filter, enabled: Boolean) {
96107
if (filter !in _filters) {
97108
throw IllegalArgumentException("Unsupported filter: $filter")
98109
}
99-
val changed = if (enabled) _selectedFilters.add(filter) else _selectedFilters.remove(filter)
110+
val changed = if (enabled) {
111+
_selectedFiltersList.add(filter)
112+
} else {
113+
_selectedFiltersList.remove(filter)
114+
}
100115
if (changed) {
101-
_selectedFilterChips = _selectedFilters.mapTo(mutableListOf()) { it.asChip(true) }
102-
val index = _filterChips.indexOfFirst { it.filter == filter }
103-
_filterChips[index] = filter.asChip(enabled)
116+
_selectedFilterChipsList =
117+
_selectedFiltersList.mapTo(mutableListOf()) { it.asChip(true) }
118+
val index = _filterChipsList.indexOfFirst { it.filter == filter }
119+
_filterChipsList[index] = filter.asChip(enabled)
104120

105121
publish(true)
106122
}
107123
}
108124

109125
override fun clearFilters() {
110-
if (_selectedFilters.isNotEmpty()) {
111-
_selectedFilters.clear()
112-
_selectedFilterChips.clear()
113-
_filterChips = _filterChips.mapTo(mutableListOf()) {
126+
if (_selectedFiltersList.isNotEmpty()) {
127+
_selectedFiltersList.clear()
128+
_selectedFilterChipsList.clear()
129+
_filterChipsList = _filterChipsList.mapTo(mutableListOf()) {
114130
if (it.isSelected) it.copy(isSelected = false) else it
115131
}
116132

mobile/src/main/java/com/google/samples/apps/iosched/ui/filters/FiltersViewModelDelegateModule.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@
1616

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

19+
import com.google.samples.apps.iosched.shared.di.ApplicationScope
1920
import dagger.Module
2021
import dagger.Provides
2122
import dagger.hilt.InstallIn
2223
import dagger.hilt.components.SingletonComponent
24+
import kotlinx.coroutines.CoroutineScope
2325

2426
@InstallIn(SingletonComponent::class)
2527
@Module
2628
class FiltersViewModelDelegateModule {
2729

2830
@Provides
29-
fun provideFiltersViewModelDelegate(): FiltersViewModelDelegate = FiltersViewModelDelegateImpl()
31+
fun provideFiltersViewModelDelegate(
32+
@ApplicationScope applicationScope: CoroutineScope
33+
): FiltersViewModelDelegate = FiltersViewModelDelegateImpl(applicationScope)
3034
}

mobile/src/main/java/com/google/samples/apps/iosched/ui/schedule/ScheduleViewModel.kt

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package com.google.samples.apps.iosched.ui.schedule
1818

1919
import androidx.lifecycle.ViewModel
20-
import androidx.lifecycle.asLiveData
2120
import androidx.lifecycle.viewModelScope
2221
import com.google.samples.apps.iosched.R
2322
import com.google.samples.apps.iosched.model.ConferenceDay
@@ -37,6 +36,7 @@ import com.google.samples.apps.iosched.shared.domain.users.StarEventAndNotifyUse
3736
import com.google.samples.apps.iosched.shared.domain.users.StarEventParameter
3837
import com.google.samples.apps.iosched.shared.fcm.TopicSubscriber
3938
import com.google.samples.apps.iosched.shared.result.Result
39+
import com.google.samples.apps.iosched.shared.result.Result.Error
4040
import com.google.samples.apps.iosched.shared.result.Result.Success
4141
import com.google.samples.apps.iosched.shared.result.data
4242
import com.google.samples.apps.iosched.shared.result.successOr
@@ -96,19 +96,15 @@ class ScheduleViewModel @Inject constructor(
9696
SignInViewModelDelegate by signInViewModelDelegate {
9797

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

108-
// TODO: Replace with timeZoneIdFlow when SearchViewModel is migrated
109-
val timeZoneId = timeZoneIdFlow.asLiveData()
110-
111-
val isConferenceTimeZone: StateFlow<Boolean> = timeZoneIdFlow.mapLatest { zoneId ->
107+
val isConferenceTimeZone: StateFlow<Boolean> = timeZoneId.mapLatest { zoneId ->
112108
TimeUtils.isConferenceTimeZone(zoneId)
113109
}.stateIn(viewModelScope, Lazily, true)
114110

@@ -145,11 +141,11 @@ class ScheduleViewModel @Inject constructor(
145141
}
146142
.onEach {
147143
// Side effect: show error messages coming from LoadScheduleUserSessionsUseCase
148-
if (it is Result.Error) {
144+
if (it is Error) {
149145
_errorMessage.tryOffer(it.exception.message ?: "Error")
150146
}
151147
// Side effect: show snackbar if the result contains a message
152-
if (it is Result.Success) {
148+
if (it is Success) {
153149
it.data.userMessage?.type?.stringRes()?.let { messageId ->
154150
// There is a message to display:
155151
snackbarMessageManager.addMessage(
@@ -171,7 +167,7 @@ class ScheduleViewModel @Inject constructor(
171167

172168
// Expose new UI data when loadSessionsResult changes
173169
val scheduleUiData: StateFlow<ScheduleUiData> =
174-
loadSessionsResult.combineTransform(timeZoneIdFlow) { sessions, timeZone ->
170+
loadSessionsResult.combineTransform(timeZoneId) { sessions, timeZone ->
175171
sessions.data?.let { data ->
176172
dayIndexer = data.dayIndexer
177173
emit(
@@ -301,7 +297,7 @@ class ScheduleViewModel @Inject constructor(
301297
)
302298
)
303299
// Show an error message if a star request fails
304-
if (result is Result.Error) {
300+
if (result is Error) {
305301
snackbarMessageManager.addMessage(SnackbarMessage(R.string.event_star_error))
306302
}
307303
}

mobile/src/main/java/com/google/samples/apps/iosched/ui/search/SearchFragment.kt

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,21 @@ import android.view.inputmethod.InputMethodManager
2626
import android.widget.SearchView
2727
import androidx.core.view.updatePadding
2828
import androidx.fragment.app.viewModels
29-
import androidx.lifecycle.Observer
3029
import androidx.navigation.fragment.findNavController
3130
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
3231
import com.google.samples.apps.iosched.R
3332
import com.google.samples.apps.iosched.databinding.FragmentSearchBinding
3433
import com.google.samples.apps.iosched.shared.analytics.AnalyticsHelper
35-
import com.google.samples.apps.iosched.shared.result.EventObserver
3634
import com.google.samples.apps.iosched.ui.MainNavigationFragment
3735
import com.google.samples.apps.iosched.ui.search.SearchFragmentDirections.Companion.toSessionDetail
3836
import com.google.samples.apps.iosched.ui.search.SearchFragmentDirections.Companion.toSpeakerDetail
3937
import com.google.samples.apps.iosched.ui.sessioncommon.SessionsAdapter
4038
import com.google.samples.apps.iosched.util.doOnApplyWindowInsets
39+
import com.google.samples.apps.iosched.util.launchAndRepeatWithViewLifecycle
4140
import com.google.samples.apps.iosched.util.openWebsiteUrl
4241
import dagger.hilt.android.AndroidEntryPoint
42+
import kotlinx.coroutines.flow.collect
43+
import kotlinx.coroutines.launch
4344
import javax.inject.Inject
4445
import javax.inject.Named
4546

@@ -63,7 +64,7 @@ class SearchFragment : MainNavigationFragment() {
6364
inflater: LayoutInflater,
6465
container: ViewGroup?,
6566
savedInstanceState: Bundle?
66-
): View? {
67+
): View {
6768
val themedInflater =
6869
inflater.cloneInContext(ContextThemeWrapper(requireActivity(), R.style.AppTheme_Detail))
6970
binding = FragmentSearchBinding.inflate(themedInflater, container, false).apply {
@@ -74,31 +75,6 @@ class SearchFragment : MainNavigationFragment() {
7475

7576
override fun onActivityCreated(savedInstanceState: Bundle?) {
7677
super.onActivityCreated(savedInstanceState)
77-
78-
viewModel.searchResults.observe(
79-
viewLifecycleOwner,
80-
Observer {
81-
sessionsAdapter.submitList(it)
82-
}
83-
)
84-
viewModel.navigateToSessionAction.observe(
85-
viewLifecycleOwner,
86-
EventObserver { sessionId ->
87-
findNavController().navigate(toSessionDetail(sessionId))
88-
}
89-
)
90-
viewModel.navigateToSpeakerAction.observe(
91-
viewLifecycleOwner,
92-
EventObserver { speakerId ->
93-
findNavController().navigate(toSpeakerDetail(speakerId))
94-
}
95-
)
96-
viewModel.navigateToCodelabAction.observe(
97-
viewLifecycleOwner,
98-
EventObserver { url ->
99-
openWebsiteUrl(requireActivity(), url)
100-
}
101-
)
10278
analyticsHelper.sendScreenView("Search", requireActivity())
10379
}
10480

@@ -154,6 +130,29 @@ class SearchFragment : MainNavigationFragment() {
154130
}
155131
}
156132

133+
launchAndRepeatWithViewLifecycle {
134+
launch {
135+
viewModel.searchResults.collect {
136+
sessionsAdapter.submitList(it)
137+
}
138+
}
139+
launch {
140+
viewModel.navigationActions.collect { event ->
141+
when (event) {
142+
is SearchNavigationAction.OpenSession -> {
143+
findNavController().navigate(toSessionDetail(event.sessionId))
144+
}
145+
is SearchNavigationAction.OpenSpeaker -> {
146+
findNavController().navigate(toSpeakerDetail(event.speakerId))
147+
}
148+
is SearchNavigationAction.OpenCodelab -> {
149+
openWebsiteUrl(requireActivity(), event.codelabUrl)
150+
}
151+
}
152+
}
153+
}
154+
}
155+
157156
if (savedInstanceState == null) {
158157
// On first entry, show the filters.
159158
findFiltersFragment().showFiltersSheet()

0 commit comments

Comments
 (0)