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 2 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.ThemeBrand
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 kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
@ -37,11 +36,7 @@ class MainActivityViewModel @Inject constructor(
) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it)
}.stateIn(
scope = viewModelScope,
initialValue = Loading,
started = SharingStarted.WhileSubscribed(5_000),
)
}.stateInUi(initialValue = Loading)
}
sealed interface MainActivityUiState {

@ -105,5 +105,10 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
*/
"-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)
implementation(libs.androidx.browser)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.coil.kt)
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.ui.NewsFeedUiState
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 kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -48,11 +47,7 @@ class BookmarksViewModel @Inject constructor(
userNewsResourceRepository.observeAllBookmarked()
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(Loading) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Loading,
)
.stateInUi(initialValue = Loading)
fun removeFromSavedResources(newsResourceId: String) {
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.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -70,27 +69,15 @@ class ForYouViewModel @Inject constructor(
}
}
.map { it.firstOrNull() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
.stateInUi(initialValue = null)
val isSyncing = syncManager.isSyncing
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
.stateInUi(initialValue = false)
val feedState: StateFlow<NewsFeedUiState> =
userNewsResourceRepository.observeAllForFollowedTopics()
.map(NewsFeedUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsFeedUiState.Loading,
)
.stateInUi(initialValue = NewsFeedUiState.Loading)
val onboardingUiState: StateFlow<OnboardingUiState> =
combine(
@ -103,11 +90,7 @@ class ForYouViewModel @Inject constructor(
OnboardingUiState.NotShown
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = OnboardingUiState.Loading,
)
.stateInUi(initialValue = OnboardingUiState.Loading)
fun updateTopicSelection(topicId: String, isChecked: Boolean) {
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.model.data.FollowableTopic
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.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = InterestsViewModel.Factory::class)
@ -57,11 +56,7 @@ class InterestsViewModel @AssistedInject constructor(
selectedTopicId,
getFollowableTopics(sortBy = TopicSortField.NAME),
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
).stateInUi(initialValue = InterestsUiState.Loading)
fun followTopic(followedTopicId: String, followed: Boolean) {
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.GetSearchContentsUseCase
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 kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -75,20 +74,12 @@ class SearchViewModel @Inject constructor(
}
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchResultUiState.Loading,
)
}.stateInUi(initialValue = SearchResultUiState.Loading)
val recentSearchQueriesUiState: StateFlow<RecentSearchQueriesUiState> =
recentSearchQueriesUseCase()
.map(RecentSearchQueriesUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = RecentSearchQueriesUiState.Loading,
)
.stateInUi(initialValue = RecentSearchQueriesUiState.Loading)
fun onSearchQueryChanged(query: String) {
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.feature.settings.impl.SettingsUiState.Loading
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 kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ -47,11 +45,7 @@ class SettingsViewModel @Inject constructor(
),
)
}
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5.seconds.inWholeMilliseconds),
initialValue = Loading,
)
.stateInUi(initialValue = Loading)
fun updateThemeBrand(themeBrand: ThemeBrand) {
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.Topic
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.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = TopicViewModel.Factory::class)
@ -51,22 +50,14 @@ class TopicViewModel @AssistedInject constructor(
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TopicUiState.Loading,
)
.stateInUi(initialValue = TopicUiState.Loading)
val newsUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicId,
userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository,
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsUiState.Loading,
)
.stateInUi(initialValue = NewsUiState.Loading)
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {

Loading…
Cancel
Save