diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt index 2d22b7d9cd..8641a49bda 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt @@ -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 = userDataRepository.userData.map { Success(it) - }.stateIn( - scope = viewModelScope, - initialValue = Loading, - started = SharingStarted.WhileSubscribed(5_000), - ) + }.stateInUi(initialValue = Loading) } sealed interface MainActivityUiState { diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 7ee7b0e90d..cab2a6731e 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -105,5 +105,10 @@ private inline fun Project.configureKotlin() = */ "-Xconsistent-data-class-copy-visibility", ) + freeCompilerArgs.add( + // Enable context parameters (experimental, Kotlin 2.x). + // Used by Flow.stateInUi which declares context(viewModel: ViewModel). + "-Xcontext-parameters", + ) } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5606cb5d16..7cd4d593d9 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -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) diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FlowExtensions.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FlowExtensions.kt new file mode 100644 index 0000000000..f39f609b78 --- /dev/null +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FlowExtensions.kt @@ -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 Flow.stateInUi(initialValue: T): StateFlow = stateIn( + scope = viewModel.viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = initialValue, +) diff --git a/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt index f36c9d31fd..ff81977192 100644 --- a/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt @@ -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, 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 { diff --git a/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt index c54551c0bb..e2ffedbc42 100644 --- a/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt @@ -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 = userNewsResourceRepository.observeAllForFollowedTopics() .map(NewsFeedUiState::Success) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = NewsFeedUiState.Loading, - ) + .stateInUi(initialValue = NewsFeedUiState.Loading) val onboardingUiState: StateFlow = 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 { diff --git a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt index f79d79d091..e2d2bfd6cf 100644 --- a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt @@ -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 { diff --git a/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt index 13628de70f..2f94b458dd 100644 --- a/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt @@ -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 = 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 diff --git a/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsViewModel.kt b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsViewModel.kt index 274f916d13..4f8266404c 100644 --- a/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsViewModel.kt +++ b/feature/settings/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/impl/SettingsViewModel.kt @@ -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 { diff --git a/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt index f604eb65b5..6703826791 100644 --- a/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt +++ b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt @@ -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( 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 {