Skip to content

Commit 54cdea4

Browse files
committed
refactor: extract stateInUi helper to reduce ViewModel boilerplate
Add FlowExtensions.kt in core:ui with a context(viewModel: ViewModel) stateInUi() extension that wraps the standard stateIn() pattern used across all ViewModels. - core/ui: add FlowExtensions.kt with stateInUi() - core/ui: add lifecycle-viewmodel-compose dependency - build-logic: enable -Xcontext-parameters compiler flag - app, feature/*: replace stateIn(...) calls with stateInUi()
1 parent d6abd6d commit 54cdea4

10 files changed

Lines changed: 71 additions & 75 deletions

File tree

app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
2424
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
2525
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
2626
import com.google.samples.apps.nowinandroid.core.model.data.UserData
27+
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
2728
import dagger.hilt.android.lifecycle.HiltViewModel
28-
import kotlinx.coroutines.flow.SharingStarted
2929
import kotlinx.coroutines.flow.StateFlow
3030
import kotlinx.coroutines.flow.map
31-
import kotlinx.coroutines.flow.stateIn
3231
import javax.inject.Inject
3332

3433
@HiltViewModel
@@ -37,11 +36,7 @@ class MainActivityViewModel @Inject constructor(
3736
) : ViewModel() {
3837
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
3938
Success(it)
40-
}.stateIn(
41-
scope = viewModelScope,
42-
initialValue = Loading,
43-
started = SharingStarted.WhileSubscribed(5_000),
44-
)
39+
}.stateInUi(initialValue = Loading)
4540
}
4641

4742
sealed interface MainActivityUiState {

build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,10 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
105105
*/
106106
"-Xconsistent-data-class-copy-visibility",
107107
)
108+
freeCompilerArgs.add(
109+
// Enable context parameters (experimental, Kotlin 2.x).
110+
// Used by Flow<T>.stateInUi which declares context(viewModel: ViewModel).
111+
"-Xcontext-parameters",
112+
)
108113
}
109114
}

core/ui/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies {
3030
api(projects.core.model)
3131

3232
implementation(libs.androidx.browser)
33+
implementation(libs.androidx.lifecycle.viewModelCompose)
3334
implementation(libs.coil.kt)
3435
implementation(libs.coil.kt.compose)
3536

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2024 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.samples.apps.nowinandroid.core.ui
18+
19+
import androidx.lifecycle.ViewModel
20+
import androidx.lifecycle.viewModelScope
21+
import kotlinx.coroutines.flow.Flow
22+
import kotlinx.coroutines.flow.SharingStarted
23+
import kotlinx.coroutines.flow.StateFlow
24+
import kotlinx.coroutines.flow.stateIn
25+
26+
/**
27+
* Converts a [Flow] to a [StateFlow] scoped to the [ViewModel]'s lifecycle, using
28+
* [SharingStarted.WhileSubscribed] with a 5-second stop timeout.
29+
*
30+
* Shorthand for:
31+
* ```
32+
* stateIn(
33+
* scope = viewModelScope,
34+
* started = SharingStarted.WhileSubscribed(5_000),
35+
* initialValue = initialValue,
36+
* )
37+
* ```
38+
*
39+
* The [ViewModel] context is resolved implicitly from `this` when called inside a [ViewModel].
40+
*/
41+
context(viewModel: ViewModel)
42+
fun <T> Flow<T>.stateInUi(initialValue: T): StateFlow<T> = stateIn(
43+
scope = viewModel.viewModelScope,
44+
started = SharingStarted.WhileSubscribed(5_000),
45+
initialValue = initialValue,
46+
)

feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,11 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
2626
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
2727
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
2828
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
29+
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
2930
import dagger.hilt.android.lifecycle.HiltViewModel
30-
import kotlinx.coroutines.flow.SharingStarted
3131
import kotlinx.coroutines.flow.StateFlow
3232
import kotlinx.coroutines.flow.map
3333
import kotlinx.coroutines.flow.onStart
34-
import kotlinx.coroutines.flow.stateIn
3534
import kotlinx.coroutines.launch
3635
import javax.inject.Inject
3736

@@ -48,11 +47,7 @@ class BookmarksViewModel @Inject constructor(
4847
userNewsResourceRepository.observeAllBookmarked()
4948
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
5049
.onStart { emit(Loading) }
51-
.stateIn(
52-
scope = viewModelScope,
53-
started = SharingStarted.WhileSubscribed(5_000),
54-
initialValue = Loading,
55-
)
50+
.stateInUi(initialValue = Loading)
5651

5752
fun removeFromSavedResources(newsResourceId: String) {
5853
viewModelScope.launch {

feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,14 @@ import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
2929
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
3030
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
3131
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
32+
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
3233
import dagger.hilt.android.lifecycle.HiltViewModel
3334
import kotlinx.coroutines.flow.Flow
34-
import kotlinx.coroutines.flow.SharingStarted
3535
import kotlinx.coroutines.flow.StateFlow
3636
import kotlinx.coroutines.flow.combine
3737
import kotlinx.coroutines.flow.flatMapLatest
3838
import kotlinx.coroutines.flow.flowOf
3939
import kotlinx.coroutines.flow.map
40-
import kotlinx.coroutines.flow.stateIn
4140
import kotlinx.coroutines.launch
4241
import javax.inject.Inject
4342

@@ -70,27 +69,15 @@ class ForYouViewModel @Inject constructor(
7069
}
7170
}
7271
.map { it.firstOrNull() }
73-
.stateIn(
74-
scope = viewModelScope,
75-
started = SharingStarted.WhileSubscribed(5_000),
76-
initialValue = null,
77-
)
72+
.stateInUi(initialValue = null)
7873

7974
val isSyncing = syncManager.isSyncing
80-
.stateIn(
81-
scope = viewModelScope,
82-
started = SharingStarted.WhileSubscribed(5_000),
83-
initialValue = false,
84-
)
75+
.stateInUi(initialValue = false)
8576

8677
val feedState: StateFlow<NewsFeedUiState> =
8778
userNewsResourceRepository.observeAllForFollowedTopics()
8879
.map(NewsFeedUiState::Success)
89-
.stateIn(
90-
scope = viewModelScope,
91-
started = SharingStarted.WhileSubscribed(5_000),
92-
initialValue = NewsFeedUiState.Loading,
93-
)
80+
.stateInUi(initialValue = NewsFeedUiState.Loading)
9481

9582
val onboardingUiState: StateFlow<OnboardingUiState> =
9683
combine(
@@ -103,11 +90,7 @@ class ForYouViewModel @Inject constructor(
10390
OnboardingUiState.NotShown
10491
}
10592
}
106-
.stateIn(
107-
scope = viewModelScope,
108-
started = SharingStarted.WhileSubscribed(5_000),
109-
initialValue = OnboardingUiState.Loading,
110-
)
93+
.stateInUi(initialValue = OnboardingUiState.Loading)
11194

11295
fun updateTopicSelection(topicId: String, isChecked: Boolean) {
11396
viewModelScope.launch {

feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,13 @@ import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCa
2424
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
2525
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
2626
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
27+
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
2728
import dagger.assisted.Assisted
2829
import dagger.assisted.AssistedFactory
2930
import dagger.assisted.AssistedInject
3031
import dagger.hilt.android.lifecycle.HiltViewModel
31-
import kotlinx.coroutines.flow.SharingStarted
3232
import kotlinx.coroutines.flow.StateFlow
3333
import kotlinx.coroutines.flow.combine
34-
import kotlinx.coroutines.flow.stateIn
3534
import kotlinx.coroutines.launch
3635

3736
@HiltViewModel(assistedFactory = InterestsViewModel.Factory::class)
@@ -57,11 +56,7 @@ class InterestsViewModel @AssistedInject constructor(
5756
selectedTopicId,
5857
getFollowableTopics(sortBy = TopicSortField.NAME),
5958
InterestsUiState::Interests,
60-
).stateIn(
61-
scope = viewModelScope,
62-
started = SharingStarted.WhileSubscribed(5_000),
63-
initialValue = InterestsUiState.Loading,
64-
)
59+
).stateInUi(initialValue = InterestsUiState.Loading)
6560

6661
fun followTopic(followedTopicId: String, followed: Boolean) {
6762
viewModelScope.launch {

feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,13 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
2828
import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase
2929
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
3030
import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult
31+
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
3132
import dagger.hilt.android.lifecycle.HiltViewModel
32-
import kotlinx.coroutines.flow.SharingStarted
3333
import kotlinx.coroutines.flow.StateFlow
3434
import kotlinx.coroutines.flow.catch
3535
import kotlinx.coroutines.flow.flatMapLatest
3636
import kotlinx.coroutines.flow.flowOf
3737
import kotlinx.coroutines.flow.map
38-
import kotlinx.coroutines.flow.stateIn
3938
import kotlinx.coroutines.launch
4039
import javax.inject.Inject
4140

@@ -75,20 +74,12 @@ class SearchViewModel @Inject constructor(
7574
}
7675
}
7776
}
78-
}.stateIn(
79-
scope = viewModelScope,
80-
started = SharingStarted.WhileSubscribed(5_000),
81-
initialValue = SearchResultUiState.Loading,
82-
)
77+
}.stateInUi(initialValue = SearchResultUiState.Loading)
8378

8479
val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> =
8580
recentSearchQueriesUseCase()
8681
.map(RecentSearchQueriesUiState::Success)
87-
.stateIn(
88-
scope = viewModelScope,
89-
started = SharingStarted.WhileSubscribed(5_000),
90-
initialValue = RecentSearchQueriesUiState.Loading,
91-
)
82+
.stateInUi(initialValue = RecentSearchQueriesUiState.Loading)
9283

9384
fun onSearchQueryChanged(query: String) {
9485
savedStateHandle[SEARCH_QUERY] = query

feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsViewModel.kt

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,12 @@ import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
2323
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
2424
import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Loading
2525
import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Success
26+
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
2627
import dagger.hilt.android.lifecycle.HiltViewModel
27-
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
2828
import kotlinx.coroutines.flow.StateFlow
2929
import kotlinx.coroutines.flow.map
30-
import kotlinx.coroutines.flow.stateIn
3130
import kotlinx.coroutines.launch
3231
import javax.inject.Inject
33-
import kotlin.time.Duration.Companion.seconds
3432

3533
@HiltViewModel
3634
class SettingsViewModel @Inject constructor(
@@ -47,11 +45,7 @@ class SettingsViewModel @Inject constructor(
4745
),
4846
)
4947
}
50-
.stateIn(
51-
scope = viewModelScope,
52-
started = WhileSubscribed(5.seconds.inWholeMilliseconds),
53-
initialValue = Loading,
54-
)
48+
.stateInUi(initialValue = Loading)
5549

5650
fun updateThemeBrand(themeBrand: ThemeBrand) {
5751
viewModelScope.launch {

feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,15 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
2727
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
2828
import com.google.samples.apps.nowinandroid.core.model.data.Topic
2929
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
30+
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
3031
import dagger.assisted.Assisted
3132
import dagger.assisted.AssistedFactory
3233
import dagger.assisted.AssistedInject
3334
import dagger.hilt.android.lifecycle.HiltViewModel
3435
import kotlinx.coroutines.flow.Flow
35-
import kotlinx.coroutines.flow.SharingStarted
3636
import kotlinx.coroutines.flow.StateFlow
3737
import kotlinx.coroutines.flow.combine
3838
import kotlinx.coroutines.flow.map
39-
import kotlinx.coroutines.flow.stateIn
4039
import kotlinx.coroutines.launch
4140

4241
@HiltViewModel(assistedFactory = TopicViewModel.Factory::class)
@@ -51,22 +50,14 @@ class TopicViewModel @AssistedInject constructor(
5150
userDataRepository = userDataRepository,
5251
topicsRepository = topicsRepository,
5352
)
54-
.stateIn(
55-
scope = viewModelScope,
56-
started = SharingStarted.WhileSubscribed(5_000),
57-
initialValue = TopicUiState.Loading,
58-
)
53+
.stateInUi(initialValue = TopicUiState.Loading)
5954

6055
val newsUiState: StateFlow<NewsUiState> = newsUiState(
6156
topicId = topicId,
6257
userDataRepository = userDataRepository,
6358
userNewsResourceRepository = userNewsResourceRepository,
6459
)
65-
.stateIn(
66-
scope = viewModelScope,
67-
started = SharingStarted.WhileSubscribed(5_000),
68-
initialValue = NewsUiState.Loading,
69-
)
60+
.stateInUi(initialValue = NewsUiState.Loading)
7061

7162
fun followTopicToggle(followed: Boolean) {
7263
viewModelScope.launch {

0 commit comments

Comments
 (0)