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()
pull/2099/head
h-muraki 5 months ago
parent d6abd6d3e5
commit 54cdea4102

@ -24,11 +24,10 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -37,11 +36,7 @@ class MainActivityViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map { val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it) Success(it)
}.stateIn( }.stateInUi(initialValue = Loading)
scope = viewModelScope,
initialValue = Loading,
started = SharingStarted.WhileSubscribed(5_000),
)
} }
sealed interface MainActivityUiState { sealed interface MainActivityUiState {

@ -105,5 +105,10 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
*/ */
"-Xconsistent-data-class-copy-visibility", "-Xconsistent-data-class-copy-visibility",
) )
freeCompilerArgs.add(
// Enable context parameters (experimental, Kotlin 2.x).
// Used by Flow<T>.stateInUi which declares context(viewModel: ViewModel).
"-Xcontext-parameters",
)
} }
} }

@ -30,6 +30,7 @@ dependencies {
api(projects.core.model) api(projects.core.model)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.coil.kt.compose) implementation(libs.coil.kt.compose)

@ -0,0 +1,46 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
/**
* Converts a [Flow] to a [StateFlow] scoped to the [ViewModel]'s lifecycle, using
* [SharingStarted.WhileSubscribed] with a 5-second stop timeout.
*
* Shorthand for:
* ```
* stateIn(
* scope = viewModelScope,
* started = SharingStarted.WhileSubscribed(5_000),
* initialValue = initialValue,
* )
* ```
*
* The [ViewModel] context is resolved implicitly from `this` when called inside a [ViewModel].
*/
context(viewModel: ViewModel)
fun <T> Flow<T>.stateInUi(initialValue: T): StateFlow<T> = stateIn(
scope = viewModel.viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = initialValue,
)

@ -26,12 +26,11 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -48,11 +47,7 @@ class BookmarksViewModel @Inject constructor(
userNewsResourceRepository.observeAllBookmarked() userNewsResourceRepository.observeAllBookmarked()
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success) .map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(Loading) } .onStart { emit(Loading) }
.stateIn( .stateInUi(initialValue = Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Loading,
)
fun removeFromSavedResources(newsResourceId: String) { fun removeFromSavedResources(newsResourceId: String) {
viewModelScope.launch { viewModelScope.launch {

@ -29,15 +29,14 @@ import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -70,27 +69,15 @@ class ForYouViewModel @Inject constructor(
} }
} }
.map { it.firstOrNull() } .map { it.firstOrNull() }
.stateIn( .stateInUi(initialValue = null)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
val isSyncing = syncManager.isSyncing val isSyncing = syncManager.isSyncing
.stateIn( .stateInUi(initialValue = false)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
val feedState: StateFlow<NewsFeedUiState> = val feedState: StateFlow<NewsFeedUiState> =
userNewsResourceRepository.observeAllForFollowedTopics() userNewsResourceRepository.observeAllForFollowedTopics()
.map(NewsFeedUiState::Success) .map(NewsFeedUiState::Success)
.stateIn( .stateInUi(initialValue = NewsFeedUiState.Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading,
)
val onboardingUiState: StateFlow<OnboardingUiState> = val onboardingUiState: StateFlow<OnboardingUiState> =
combine( combine(
@ -103,11 +90,7 @@ class ForYouViewModel @Inject constructor(
OnboardingUiState.NotShown OnboardingUiState.NotShown
} }
} }
.stateIn( .stateInUi(initialValue = OnboardingUiState.Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = OnboardingUiState.Loading,
)
fun updateTopicSelection(topicId: String, isChecked: Boolean) { fun updateTopicSelection(topicId: String, isChecked: Boolean) {
viewModelScope.launch { viewModelScope.launch {

@ -24,14 +24,13 @@ import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCa
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = InterestsViewModel.Factory::class) @HiltViewModel(assistedFactory = InterestsViewModel.Factory::class)
@ -57,11 +56,7 @@ class InterestsViewModel @AssistedInject constructor(
selectedTopicId, selectedTopicId,
getFollowableTopics(sortBy = TopicSortField.NAME), getFollowableTopics(sortBy = TopicSortField.NAME),
InterestsUiState::Interests, InterestsUiState::Interests,
).stateIn( ).stateInUi(initialValue = InterestsUiState.Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
fun followTopic(followedTopicId: String, followed: Boolean) { fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch { viewModelScope.launch {

@ -28,14 +28,13 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -75,20 +74,12 @@ class SearchViewModel @Inject constructor(
} }
} }
} }
}.stateIn( }.stateInUi(initialValue = SearchResultUiState.Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchResultUiState.Loading,
)
val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> = val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> =
recentSearchQueriesUseCase() recentSearchQueriesUseCase()
.map(RecentSearchQueriesUiState::Success) .map(RecentSearchQueriesUiState::Success)
.stateIn( .stateInUi(initialValue = RecentSearchQueriesUiState.Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = RecentSearchQueriesUiState.Loading,
)
fun onSearchQueryChanged(query: String) { fun onSearchQueryChanged(query: String) {
savedStateHandle[SEARCH_QUERY] = query savedStateHandle[SEARCH_QUERY] = query

@ -23,14 +23,12 @@ import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Success import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsUiState.Success
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
@ -47,11 +45,7 @@ class SettingsViewModel @Inject constructor(
), ),
) )
} }
.stateIn( .stateInUi(initialValue = Loading)
scope = viewModelScope,
started = WhileSubscribed(5.seconds.inWholeMilliseconds),
initialValue = Loading,
)
fun updateThemeBrand(themeBrand: ThemeBrand) { fun updateThemeBrand(themeBrand: ThemeBrand) {
viewModelScope.launch { viewModelScope.launch {

@ -27,16 +27,15 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.stateInUi
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = TopicViewModel.Factory::class) @HiltViewModel(assistedFactory = TopicViewModel.Factory::class)
@ -51,22 +50,14 @@ class TopicViewModel @AssistedInject constructor(
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
) )
.stateIn( .stateInUi(initialValue = TopicUiState.Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TopicUiState.Loading,
)
val newsUiState: StateFlow<NewsUiState> = newsUiState( val newsUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicId, topicId = topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
.stateIn( .stateInUi(initialValue = NewsUiState.Loading)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsUiState.Loading,
)
fun followTopicToggle(followed: Boolean) { fun followTopicToggle(followed: Boolean) {
viewModelScope.launch { viewModelScope.launch {

Loading…
Cancel
Save