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

Commit 7d22434

Browse files
JoseAlcerrecaGerrit Code Review
authored andcommitted
Merge "Migrate Speaker screen to Flows" into main
2 parents 4a50c1f + 719e79b commit 7d22434

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
@@ -23,7 +23,7 @@ import android.view.View
2323
import android.view.ViewGroup
2424
import androidx.core.view.updatePadding
2525
import androidx.fragment.app.viewModels
26-
import androidx.lifecycle.Observer
26+
import androidx.lifecycle.lifecycleScope
2727
import androidx.navigation.fragment.findNavController
2828
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
2929
import androidx.transition.TransitionInflater
@@ -40,6 +40,7 @@ import com.google.samples.apps.iosched.ui.signin.SignInDialogFragment
4040
import com.google.samples.apps.iosched.ui.speaker.SpeakerFragmentDirections.Companion.toSessionDetail
4141
import com.google.samples.apps.iosched.util.doOnApplyWindowInsets
4242
import dagger.hilt.android.AndroidEntryPoint
43+
import kotlinx.coroutines.flow.collect
4344
import java.util.concurrent.TimeUnit
4445
import javax.inject.Inject
4546
import javax.inject.Named
@@ -69,7 +70,6 @@ class SpeakerFragment : MainNavigationFragment(), OnOffsetChangedListener {
6970
container: ViewGroup?,
7071
savedInstanceState: Bundle?
7172
): View? {
72-
speakerViewModel.setSpeakerId(SpeakerFragmentArgs.fromBundle(requireArguments()).speakerId)
7373

7474
sharedElementEnterTransition =
7575
TransitionInflater.from(context).inflateTransition(R.transition.speaker_shared_enter)
@@ -95,14 +95,13 @@ class SpeakerFragment : MainNavigationFragment(), OnOffsetChangedListener {
9595
}
9696

9797
// If speaker does not have a profile image to load, we need to resume.
98-
speakerViewModel.hasNoProfileImage.observe(
99-
viewLifecycleOwner,
100-
Observer {
101-
if (it == true) {
98+
lifecycleScope.launchWhenStarted {
99+
speakerViewModel.hasNoProfileImage.collect {
100+
if (it) {
102101
startPostponedEnterTransition()
103102
}
104103
}
105-
)
104+
}
106105

107106
speakerViewModel.navigateToEventAction.observe(
108107
viewLifecycleOwner,
@@ -143,29 +142,26 @@ class SpeakerFragment : MainNavigationFragment(), OnOffsetChangedListener {
143142
insets.systemWindowInsetTop * 2
144143
}
145144
}
146-
147-
speakerViewModel.speakerUserSessions.observe(
148-
viewLifecycleOwner,
149-
Observer {
145+
lifecycleScope.launchWhenStarted {
146+
speakerViewModel.speakerUserSessions.collect {
150147
speakerAdapter.speakerSessions = it ?: emptyList()
151148
}
152-
)
149+
}
153150

154151
return binding.root
155152
}
156153

157-
override fun onActivityCreated(savedInstanceState: Bundle?) {
158-
super.onActivityCreated(savedInstanceState)
154+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
155+
super.onViewCreated(view, savedInstanceState)
159156

160-
speakerViewModel.speaker.observe(
161-
viewLifecycleOwner,
162-
Observer {
157+
lifecycleScope.launchWhenStarted {
158+
speakerViewModel.speaker.collect {
163159
if (it != null) {
164160
val pageName = "Speaker Details: ${it.name}"
165161
analyticsHelper.sendScreenView(pageName, requireActivity())
166162
}
167163
}
168-
)
164+
}
169165
}
170166

171167
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)