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

Commit 719e79b

Browse files
committed
Migrate Speaker screen to Flows
Change-Id: I9c6a5a7e8fce0833d998e328a5c5c1e2030806be
1 parent 788454e commit 719e79b

File tree

3 files changed

+70
-92
lines changed

3 files changed

+70
-92
lines changed

mobile/src/main/java/com/google/samples/apps/iosched/ui/speaker/SpeakerFragment.kt

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import android.view.ViewGroup
2424
import androidx.core.view.updatePadding
2525
import androidx.fragment.app.activityViewModels
2626
import androidx.fragment.app.viewModels
27-
import androidx.lifecycle.Observer
27+
import androidx.lifecycle.lifecycleScope
2828
import androidx.navigation.fragment.findNavController
2929
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
3030
import androidx.transition.TransitionInflater
@@ -42,6 +42,7 @@ import com.google.samples.apps.iosched.ui.signin.SignInDialogFragment
4242
import com.google.samples.apps.iosched.ui.speaker.SpeakerFragmentDirections.Companion.toSessionDetail
4343
import com.google.samples.apps.iosched.util.doOnApplyWindowInsets
4444
import dagger.hilt.android.AndroidEntryPoint
45+
import kotlinx.coroutines.flow.collect
4546
import java.util.concurrent.TimeUnit
4647
import javax.inject.Inject
4748
import javax.inject.Named
@@ -72,7 +73,6 @@ class SpeakerFragment : MainNavigationFragment(), OnOffsetChangedListener {
7273
container: ViewGroup?,
7374
savedInstanceState: Bundle?
7475
): View? {
75-
speakerViewModel.setSpeakerId(SpeakerFragmentArgs.fromBundle(requireArguments()).speakerId)
7676

7777
sharedElementEnterTransition =
7878
TransitionInflater.from(context).inflateTransition(R.transition.speaker_shared_enter)
@@ -98,14 +98,13 @@ class SpeakerFragment : MainNavigationFragment(), OnOffsetChangedListener {
9898
}
9999

100100
// If speaker does not have a profile image to load, we need to resume.
101-
speakerViewModel.hasNoProfileImage.observe(
102-
viewLifecycleOwner,
103-
Observer {
104-
if (it == true) {
101+
lifecycleScope.launchWhenStarted {
102+
speakerViewModel.hasNoProfileImage.collect {
103+
if (it) {
105104
startPostponedEnterTransition()
106105
}
107106
}
108-
)
107+
}
109108

110109
speakerViewModel.navigateToEventAction.observe(
111110
viewLifecycleOwner,
@@ -154,29 +153,26 @@ class SpeakerFragment : MainNavigationFragment(), OnOffsetChangedListener {
154153
insets.systemWindowInsetTop * 2
155154
}
156155
}
157-
158-
speakerViewModel.speakerUserSessions.observe(
159-
viewLifecycleOwner,
160-
Observer {
156+
lifecycleScope.launchWhenStarted {
157+
speakerViewModel.speakerUserSessions.collect {
161158
speakerAdapter.speakerSessions = it ?: emptyList()
162159
}
163-
)
160+
}
164161

165162
return binding.root
166163
}
167164

168-
override fun onActivityCreated(savedInstanceState: Bundle?) {
169-
super.onActivityCreated(savedInstanceState)
165+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
166+
super.onViewCreated(view, savedInstanceState)
170167

171-
speakerViewModel.speaker.observe(
172-
viewLifecycleOwner,
173-
Observer {
168+
lifecycleScope.launchWhenStarted {
169+
speakerViewModel.speaker.collect {
174170
if (it != null) {
175171
val pageName = "Speaker Details: ${it.name}"
176172
analyticsHelper.sendScreenView(pageName, requireActivity())
177173
}
178174
}
179-
)
175+
}
180176
}
181177

182178
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {

mobile/src/main/java/com/google/samples/apps/iosched/ui/speaker/SpeakerViewModel.kt

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@
1616

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

19-
import androidx.lifecycle.LiveData
20-
import androidx.lifecycle.MutableLiveData
19+
import androidx.lifecycle.SavedStateHandle
2120
import androidx.lifecycle.ViewModel
22-
import androidx.lifecycle.asFlow
23-
import androidx.lifecycle.liveData
24-
import androidx.lifecycle.switchMap
21+
import androidx.lifecycle.asLiveData
2522
import androidx.lifecycle.viewModelScope
2623
import com.google.samples.apps.iosched.model.Speaker
2724
import com.google.samples.apps.iosched.model.SpeakerId
@@ -33,16 +30,21 @@ import com.google.samples.apps.iosched.shared.domain.settings.GetTimeZoneUseCase
3330
import com.google.samples.apps.iosched.shared.domain.speakers.LoadSpeakerUseCase
3431
import com.google.samples.apps.iosched.shared.domain.speakers.LoadSpeakerUseCaseResult
3532
import com.google.samples.apps.iosched.shared.result.Result
33+
import com.google.samples.apps.iosched.shared.result.Result.Loading
3634
import com.google.samples.apps.iosched.shared.result.data
3735
import com.google.samples.apps.iosched.shared.result.successOr
3836
import com.google.samples.apps.iosched.shared.util.TimeUtils
39-
import com.google.samples.apps.iosched.shared.util.map
4037
import com.google.samples.apps.iosched.ui.sessioncommon.EventActionsViewModelDelegate
4138
import com.google.samples.apps.iosched.ui.signin.SignInViewModelDelegate
39+
import com.google.samples.apps.iosched.util.WhileViewSubscribed
4240
import dagger.hilt.android.lifecycle.HiltViewModel
4341
import kotlinx.coroutines.flow.SharingStarted
42+
import kotlinx.coroutines.flow.StateFlow
4443
import kotlinx.coroutines.flow.collect
44+
import kotlinx.coroutines.flow.flow
45+
import kotlinx.coroutines.flow.mapLatest
4546
import kotlinx.coroutines.flow.stateIn
47+
import kotlinx.coroutines.flow.transformLatest
4648
import org.threeten.bp.ZoneId
4749
import javax.inject.Inject
4850

@@ -51,6 +53,7 @@ import javax.inject.Inject
5153
*/
5254
@HiltViewModel
5355
class SpeakerViewModel @Inject constructor(
56+
savedStateHandle: SavedStateHandle,
5457
private val loadSpeakerUseCase: LoadSpeakerUseCase,
5558
private val loadSpeakerSessionsUseCase: LoadUserSessionsUseCase,
5659
getTimeZoneUseCase: GetTimeZoneUseCase,
@@ -61,55 +64,45 @@ class SpeakerViewModel @Inject constructor(
6164
SignInViewModelDelegate by signInViewModelDelegate,
6265
EventActionsViewModelDelegate by eventActionsViewModelDelegate {
6366

64-
private val speakerId = MutableLiveData<String>()
67+
// TODO: remove hardcoded string when https://issuetracker.google.com/136967621 is available
68+
private val speakerId: SpeakerId? = savedStateHandle.get<SpeakerId>("speaker_id")
6569

66-
private val loadSpeakerUseCaseResult: LiveData<Result<LoadSpeakerUseCaseResult>> =
67-
speakerId.switchMap { speakerId ->
68-
liveData {
69-
emit(loadSpeakerUseCase(speakerId))
70-
}
71-
}
70+
private val loadSpeakerUseCaseResult: StateFlow<Result<LoadSpeakerUseCaseResult>> =
71+
flow {
72+
speakerId?.let { emit(loadSpeakerUseCase(speakerId)) }
73+
}.stateIn(viewModelScope, SharingStarted.Eagerly, Loading)
7274

73-
val speakerUserSessions: LiveData<List<UserSession>> =
74-
loadSpeakerUseCaseResult.switchMap { speaker ->
75-
liveData {
76-
emit(emptyList()) // Reset value
77-
speaker.data?.let {
78-
loadSpeakerSessionsUseCase(it.speaker.id to it.sessionIds).collect {
79-
it.data?.let { data ->
80-
emit(data)
81-
}
75+
val speakerUserSessions: StateFlow<List<UserSession>> =
76+
loadSpeakerUseCaseResult.transformLatest { speaker ->
77+
speaker.data?.let {
78+
loadSpeakerSessionsUseCase(it.speaker.id to it.sessionIds).collect {
79+
it.data?.let { data ->
80+
emit(data)
8281
}
8382
}
8483
}
85-
}
84+
}.stateIn(viewModelScope, WhileViewSubscribed, emptyList())
8685

87-
val speaker: LiveData<Speaker?> = loadSpeakerUseCaseResult.map {
86+
val speaker: StateFlow<Speaker?> = loadSpeakerUseCaseResult.mapLatest {
8887
it.data?.speaker
89-
}
88+
}.stateIn(viewModelScope, WhileViewSubscribed, null)
9089

91-
val hasNoProfileImage: LiveData<Boolean> = loadSpeakerUseCaseResult.map {
90+
val hasNoProfileImage: StateFlow<Boolean> = loadSpeakerUseCaseResult.mapLatest {
9291
it.data?.speaker?.imageUrl.isNullOrEmpty()
93-
}
92+
}.stateIn(viewModelScope, WhileViewSubscribed, true)
9493

95-
val timeZoneId = liveData {
96-
val timeZone = getTimeZoneUseCase(Unit)
97-
if (timeZone.successOr(true)) {
94+
// Exposed to the view as a StateFlow but it's a one-shot operation.
95+
// TODO: Rename with timeZoneId when all usages are migrated
96+
val timeZoneIdFlow = flow<ZoneId> {
97+
if (getTimeZoneUseCase(Unit).successOr(true)) {
9898
emit(TimeUtils.CONFERENCE_TIMEZONE)
9999
} else {
100100
emit(ZoneId.systemDefault())
101101
}
102-
}
102+
}.stateIn(viewModelScope, SharingStarted.Lazily, TimeUtils.CONFERENCE_TIMEZONE)
103103

104-
val timeZoneIdFlow = timeZoneId.asFlow()
105-
.stateIn(viewModelScope, SharingStarted.Lazily, ZoneId.systemDefault())
106-
107-
/**
108-
* Provides the speaker ID which initiates all data loading.
109-
*/
110-
fun setSpeakerId(id: SpeakerId) {
111-
speakerId.value = id
112-
}
104+
// TODO: Replace with timeZoneIdFlow when SearchViewModel is migrated
105+
val timeZoneId = timeZoneIdFlow.asLiveData()
113106

114107
override fun onStarClicked(userSession: UserSession) {
115108
eventActionsViewModelDelegate.onStarClicked(userSession)
@@ -124,7 +117,7 @@ class SpeakerViewModel @Inject constructor(
124117
val sessionId = userSession.userEvent.id
125118
val sessions = speakerUserSessions.value
126119

127-
if (sessions != null) {
120+
if (sessions.isNotEmpty()) {
128121
val session = sessions.first { it.session.id == sessionId }.session
129122
analyticsHelper.logUiEvent(session.title, AnalyticsActions.STARRED)
130123
}

mobile/src/test/java/com/google/samples/apps/iosched/ui/speaker/SpeakerViewModelTest.kt

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@
1919
package com.google.samples.apps.iosched.ui.speaker
2020

2121
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
22-
import com.google.samples.apps.iosched.androidtest.util.LiveDataTestUtil
23-
import com.google.samples.apps.iosched.androidtest.util.observeForTesting
22+
import androidx.lifecycle.SavedStateHandle
2423
import com.google.samples.apps.iosched.model.TestDataRepository
2524
import com.google.samples.apps.iosched.shared.analytics.AnalyticsHelper
2625
import com.google.samples.apps.iosched.shared.data.session.DefaultSessionRepository
@@ -38,6 +37,7 @@ import com.google.samples.apps.iosched.test.util.fakes.FakeSignInViewModelDelega
3837
import com.google.samples.apps.iosched.ui.schedule.TestUserEventDataSource
3938
import com.google.samples.apps.iosched.ui.sessioncommon.EventActionsViewModelDelegate
4039
import com.google.samples.apps.iosched.ui.signin.SignInViewModelDelegate
40+
import kotlinx.coroutines.flow.first
4141
import kotlinx.coroutines.test.TestCoroutineDispatcher
4242
import org.junit.Assert.assertEquals
4343
import org.junit.Rule
@@ -61,48 +61,36 @@ class SpeakerViewModelTest {
6161
// Given a speaker view model
6262
val viewModel = createViewModel()
6363

64-
// When the speaker ID is set
65-
viewModel.setSpeakerId(TestData.speaker1.id)
66-
6764
// Then the speaker is loaded
68-
assertEquals(TestData.speaker1, LiveDataTestUtil.getValue(viewModel.speaker))
65+
assertEquals(TestData.speaker1, viewModel.speaker.first())
6966
}
7067

7168
@Test
7269
fun setSpeakerId_loadsSpeakersEvents_singleEvent() = coroutineRule.runBlockingTest {
7370
// Given a speaker view model
74-
val viewModel = createViewModel()
75-
76-
// When the ID of a speaker with a single event is set
77-
viewModel.setSpeakerId(TestData.speaker3.id)
71+
val viewModel = createViewModel(speakerId = TestData.speaker3.id)
7872

79-
viewModel.speakerUserSessions.observeForTesting {
80-
// Then the speakers event is loaded
81-
assertEquals(
82-
listOf(TestData.userSession2),
83-
viewModel.speakerUserSessions.value
84-
)
85-
}
73+
// Then the speakers event is loaded
74+
assertEquals(
75+
listOf(TestData.userSession2),
76+
viewModel.speakerUserSessions.first()
77+
)
8678
}
8779

8880
@Test
8981
fun setSpeakerId_loadsSpeakersEvents_multipleEvents() = coroutineRule.runBlockingTest {
9082
// Given a speaker view model
9183
val viewModel = createViewModel()
9284

93-
viewModel.speakerUserSessions.observeForTesting {
94-
// When the ID of a speaker with multiple events is set
95-
viewModel.setSpeakerId(TestData.speaker1.id)
96-
97-
// Then the speakers events are loaded
98-
assertEquals(
99-
listOf(TestData.userSession0, TestData.userSession3, TestData.userSession4),
100-
viewModel.speakerUserSessions.value
101-
)
102-
}
85+
// Then the speakers events are loaded
86+
assertEquals(
87+
listOf(TestData.userSession0, TestData.userSession3, TestData.userSession4),
88+
viewModel.speakerUserSessions.first()
89+
)
10390
}
10491

10592
private fun createViewModel(
93+
speakerId: String? = TestData.speaker1.id,
10694
loadSpeakerUseCase: LoadSpeakerUseCase =
10795
LoadSpeakerUseCase(TestDataRepository, TestCoroutineDispatcher()),
10896
loadSpeakerSessionsUseCase: LoadUserSessionsUseCase = LoadUserSessionsUseCase(
@@ -121,12 +109,13 @@ class SpeakerViewModelTest {
121109
analyticsHelper: AnalyticsHelper = FakeAnalyticsHelper()
122110
): SpeakerViewModel {
123111
return SpeakerViewModel(
124-
loadSpeakerUseCase,
125-
loadSpeakerSessionsUseCase,
126-
getTimeZoneUseCase,
127-
signInViewModelDelegate,
128-
eventActionsDelegate,
129-
analyticsHelper
112+
savedStateHandle = SavedStateHandle(mapOf("speaker_id" to speakerId)),
113+
loadSpeakerUseCase = loadSpeakerUseCase,
114+
loadSpeakerSessionsUseCase = loadSpeakerSessionsUseCase,
115+
getTimeZoneUseCase = getTimeZoneUseCase,
116+
signInViewModelDelegate = signInViewModelDelegate,
117+
eventActionsViewModelDelegate = eventActionsDelegate,
118+
analyticsHelper = analyticsHelper
130119
)
131120
}
132121
}

0 commit comments

Comments
 (0)