Merge branch 'android:main' into patch-2

pull/412/head
Simon Marquis 2 years ago committed by GitHub
commit 18e58678e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -15,7 +15,7 @@ follows Android design and development best practices and is intended to be a us
for developers. As a running app, it's intended to help developers keep up-to-date with the world
of Android development by providing regular news updates.
The app is currently in early stage development and is not yet available on the Play Store.
The app is currently in development. The `demoRelease` variant is [available on the Play Store in open beta](https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid).
# Features

@ -26,8 +26,8 @@ plugins {
android {
defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 2
versionName = "0.0.2" // X.Y.Z; X = Major, Y = minor, Z = Patch level
versionCode = 3
versionName = "0.0.3" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"

@ -14,8 +14,8 @@ enum class FlavorDimension {
// purposes, or from a production backend server which supplies up-to-date, real content.
// These two product flavors reflect this behaviour.
enum class Flavor (val dimension : FlavorDimension, val applicationIdSuffix : String? = null) {
demo(FlavorDimension.contentType, ".demo"),
prod(FlavorDimension.contentType)
demo(FlavorDimension.contentType),
prod(FlavorDimension.contentType, ".prod")
}
fun Project.configureFlavors(

@ -50,4 +50,7 @@ class OfflineFirstUserDataRepository @Inject constructor(
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) =
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) =
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
}

@ -62,4 +62,9 @@ interface UserDataRepository {
* Sets the desired dark theme config.
*/
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
/**
* Sets whether the user has completed the onboarding process.
*/
suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean)
}

@ -63,4 +63,8 @@ class FakeUserDataRepository @Inject constructor(
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
}
}

@ -58,7 +58,8 @@ class OfflineFirstUserDataRepositoryTest {
followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false
),
subject.userDataStream.first()
)
@ -187,4 +188,15 @@ class OfflineFirstUserDataRepositoryTest {
.first()
)
}
@Test
fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() =
runTest {
subject.setFollowedTopicIds(setOf("1"))
subject.setShouldHideOnboarding(true)
assertEquals(true, subject.userDataStream.first().shouldHideOnboarding)
subject.setFollowedTopicIds(emptySet())
assertEquals(false, subject.userDataStream.first().shouldHideOnboarding)
}
}

@ -59,6 +59,7 @@ dependencies {
implementation(project(":core:model"))
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.kotlinx.coroutines.android)

@ -52,7 +52,8 @@ class NiaPreferencesDataSource @Inject constructor(
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT ->
DarkThemeConfig.LIGHT
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK
}
},
shouldHideOnboarding = it.shouldHideOnboarding
)
}
@ -62,6 +63,7 @@ class NiaPreferencesDataSource @Inject constructor(
it.copy {
followedTopicIds.clear()
followedTopicIds.putAll(topicIds.associateWith { true })
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -78,6 +80,7 @@ class NiaPreferencesDataSource @Inject constructor(
} else {
followedTopicIds.remove(topicId)
}
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -91,6 +94,7 @@ class NiaPreferencesDataSource @Inject constructor(
it.copy {
followedAuthorIds.clear()
followedAuthorIds.putAll(authorIds.associateWith { true })
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -107,6 +111,7 @@ class NiaPreferencesDataSource @Inject constructor(
} else {
followedAuthorIds.remove(authorId)
}
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
@ -188,4 +193,19 @@ class NiaPreferencesDataSource @Inject constructor(
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
userPreferences.updateData {
it.copy {
this.shouldHideOnboarding = shouldHideOnboarding
}
}
}
}
private fun UserPreferencesKt.Dsl.updateShouldHideOnboardingIfNecessary() {
if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) {
shouldHideOnboarding = false
}
}

@ -43,4 +43,6 @@ message UserPreferences {
ThemeBrandProto theme_brand = 16;
DarkThemeConfigProto dark_theme_config = 17;
bool should_hide_onboarding = 18;
}

@ -0,0 +1,137 @@
/*
* Copyright 2022 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.datastore
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class NiaPreferencesDataSourceTest {
private lateinit var subject: NiaPreferencesDataSource
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before
fun setup() {
subject = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
)
}
@Test
fun shouldHideOnboardingIsFalseByDefault() = runTest {
assertEquals(false, subject.userDataStream.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboardingIsTrueWhenSet() = runTest {
subject.setShouldHideOnboarding(true)
assertEquals(true, subject.userDataStream.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsLastAuthor_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting a single author.
subject.toggleFollowedAuthorId("1", true)
subject.setShouldHideOnboarding(true)
// When: they unfollow that author.
subject.toggleFollowedAuthorId("1", false)
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting a single topic.
subject.toggleFollowedTopicId("1", true)
subject.setShouldHideOnboarding(true)
// When: they unfollow that topic.
subject.toggleFollowedTopicId("1", false)
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllAuthors_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting several authors.
subject.setFollowedAuthorIds(setOf("1", "2"))
subject.setShouldHideOnboarding(true)
// When: they unfollow those authors.
subject.setFollowedAuthorIds(emptySet())
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting several topics.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setShouldHideOnboarding(true)
// When: they unfollow those topics.
subject.setFollowedTopicIds(emptySet())
// Then: onboarding should be shown again
assertEquals(false, subject.userDataStream.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllTopicsButNotAuthors_shouldHideOnboardingIsTrue() =
runTest {
// Given: user completes onboarding by selecting several topics and authors.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setFollowedAuthorIds(setOf("3", "4"))
subject.setShouldHideOnboarding(true)
// When: they unfollow just the topics.
subject.setFollowedTopicIds(emptySet())
// Then: onboarding should still be dismissed
assertEquals(true, subject.userDataStream.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllAuthorsButNotTopics_shouldHideOnboardingIsTrue() =
runTest {
// Given: user completes onboarding by selecting several topics and authors.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setFollowedAuthorIds(setOf("3", "4"))
subject.setShouldHideOnboarding(true)
// When: they unfollow just the authors.
subject.setFollowedAuthorIds(emptySet())
// Then: onboarding should still be dismissed
assertEquals(true, subject.userDataStream.first().shouldHideOnboarding)
}
}

@ -36,29 +36,18 @@ class GetFollowableTopicsStreamUseCase @Inject constructor(
/**
* Returns a list of topics with their associated followed state.
*
* @param followedTopicIdsStream - the set of topic ids which are currently being followed. By
* default the followed topic ids are supplied from the user data repository, but in certain
* scenarios, such as when creating a temporary set of followed topics, you may wish to override
* this parameter to supply your own list of topic ids. @see ForYouViewModel for an example of
* this.
* @param sortBy - the field used to sort the topics. Default NONE = no sorting.
*/
operator fun invoke(
followedTopicIdsStream: Flow<Set<String>> =
userDataRepository.userDataStream.map { userdata ->
userdata.followedTopics
},
sortBy: TopicSortField = NONE
): Flow<List<FollowableTopic>> {
operator fun invoke(sortBy: TopicSortField = NONE): Flow<List<FollowableTopic>> {
return combine(
followedTopicIdsStream,
userDataRepository.userDataStream,
topicsRepository.getTopicsStream()
) { followedIds, topics ->
) { userData, topics ->
val followedTopics = topics
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in followedIds
isFollowed = topic.id in userData.followedTopics
)
}
when (sortBy) {

@ -1,48 +0,0 @@
/*
* Copyright 2022 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.domain
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
/**
* A use case which obtains a sorted list of authors with their followed state obtained from user
* data.
*/
class GetPersistentSortedFollowableAuthorsStreamUseCase @Inject constructor(
authorsRepository: AuthorsRepository,
private val userDataRepository: UserDataRepository
) {
private val getSortedFollowableAuthorsStream =
GetSortedFollowableAuthorsStreamUseCase(authorsRepository)
/**
* Returns a list of authors with their associated followed state sorted alphabetically by name.
*/
operator fun invoke(): Flow<List<FollowableAuthor>> {
return userDataRepository.userDataStream.map { userdata ->
userdata.followedAuthors
}.flatMapLatest {
getSortedFollowableAuthorsStream(it)
}
}
}

@ -17,33 +17,34 @@
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
/**
* A use case which obtains a list of authors sorted alphabetically by name with their followed
* state.
*/
class GetSortedFollowableAuthorsStreamUseCase @Inject constructor(
private val authorsRepository: AuthorsRepository
private val authorsRepository: AuthorsRepository,
private val userDataRepository: UserDataRepository
) {
/**
* Returns a list of authors with their associated followed state sorted alphabetically by name.
*
* @param followedTopicIds - the set of topic ids which are currently being followed.
*/
operator fun invoke(followedAuthorIds: Set<String>): Flow<List<FollowableAuthor>> {
return authorsRepository.getAuthorsStream().map { authors ->
authors
.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in followedAuthorIds
)
}
operator fun invoke(): Flow<List<FollowableAuthor>> =
combine(
authorsRepository.getAuthorsStream(),
userDataRepository.userDataStream
) { authors, userData ->
authors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in userData.followedAuthors
)
}
.sortedBy { it.author.name }
}
}
}

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.domain
import androidx.compose.runtime.snapshotFlow
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -63,31 +62,6 @@ class GetFollowableTopicsStreamUseCaseTest {
)
}
@Test
fun whenFollowedTopicIdsSupplied_differentFollowedTopicsAreReturned() = runTest {
// Obtain a stream of followable topics, specifying a list of topic ids which are currently
// followed.
val followableTopics = useCase(
followedTopicIdsStream = snapshotFlow { setOf(testTopics[1].id) }
)
// Send some test topics and their followed state.
topicsRepository.sendTopics(testTopics)
userDataRepository.setFollowedTopicIds(setOf(testTopics[0].id))
// Check that the topic ids supplied to the use case are used for the bookmark state, not
// the topic ids in the user data repository.
assertEquals(
followableTopics.first(),
listOf(
FollowableTopic(testTopics[0], false),
FollowableTopic(testTopics[1], true),
FollowableTopic(testTopics[2], false),
)
)
}
@Test
fun whenSortOrderIsByName_topicsSortedByNameAreReturned() = runTest {

@ -1,95 +0,0 @@
/*
* Copyright 2022 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.domain
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class GetPersistentSortedFollowableAuthorsStreamUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val authorsRepository = TestAuthorsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetPersistentSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
@Test
fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest {
// Obtain the stream of authors.
val followableAuthorsStream = useCase()
// Supply some authors and their followed state in user data.
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(setOf(sampleAuthor1.id, sampleAuthor3.id))
// Check that the authors have been sorted, and that the followed state is correct.
assertEquals(
listOf(
FollowableAuthor(sampleAuthor2, false),
FollowableAuthor(sampleAuthor1, true),
FollowableAuthor(sampleAuthor3, true)
),
followableAuthorsStream.first()
)
}
}
private val sampleAuthor1 =
Author(
id = "Author1",
name = "Mandy",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleAuthor2 =
Author(
id = "Author2",
name = "Andy",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleAuthor3 =
Author(
id = "Author3",
name = "Sandy",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleAuthors = listOf(sampleAuthor1, sampleAuthor2, sampleAuthor3)

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@ -32,14 +33,21 @@ class GetSortedFollowableAuthorsStreamUseCaseTest {
val mainDispatcherRule = MainDispatcherRule()
private val authorsRepository = TestAuthorsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetSortedFollowableAuthorsStreamUseCase(authorsRepository)
val useCase = GetSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
@Test
fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest {
// Specify some authors which the user is following.
userDataRepository.setFollowedAuthorIds(setOf(sampleAuthor1.id))
// Obtain the stream of authors, specifying their followed state.
val followableAuthorsStream = useCase(followedAuthorIds = setOf(sampleAuthor1.id))
val followableAuthorsStream = useCase()
// Supply some authors.
authorsRepository.sendAuthors(sampleAuthors)

@ -25,4 +25,5 @@ data class UserData(
val followedAuthors: Set<String>,
val themeBrand: ThemeBrand,
val darkThemeConfig: DarkThemeConfig,
val shouldHideOnboarding: Boolean
)

@ -30,7 +30,8 @@ private val emptyUserData = UserData(
followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false
)
class TestUserDataRepository : UserDataRepository {
@ -90,6 +91,12 @@ class TestUserDataRepository : UserDataRepository {
}
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
currentUserData.let { current ->
_userData.tryEmit(current.copy(shouldHideOnboarding = shouldHideOnboarding))
}
}
/**
* A test-only API to allow setting/unsetting of bookmarks.
*

@ -30,9 +30,9 @@ import androidx.metrics.performance.PerformanceMetricsState.Holder
import kotlinx.coroutines.CoroutineScope
/**
* Retrieves [PerformanceMetricsState.MetricsStateHolder] from current [LocalView] and
* Retrieves [PerformanceMetricsState.Holder] from current [LocalView] and
* remembers it until the View changes.
* @see PerformanceMetricsState.getForHierarchy
* @see PerformanceMetricsState.getHolderForHierarchy
*/
@Composable
fun rememberMetricsStateHolder(): Holder {

@ -54,7 +54,7 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
@ -77,7 +77,7 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
@ -100,8 +100,8 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
onboardingUiState =
OnboardingUiState.Shown(
topics = testTopics,
authors = testAuthors
),
@ -149,8 +149,8 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
onboardingUiState =
OnboardingUiState.Shown(
// Follow one topic
topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
@ -201,8 +201,8 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
onboardingUiState =
OnboardingUiState.Shown(
// Follow one topic
topics = testTopics,
authors = testAuthors.mapIndexed { index, testAuthor ->
@ -253,8 +253,8 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
onboardingUiState =
OnboardingUiState.Shown(
topics = testTopics,
authors = testAuthors
),
@ -280,7 +280,7 @@ class ForYouScreenTest {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
@ -302,7 +302,7 @@ class ForYouScreenTest {
composeTestRule.setContent {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)

@ -1,41 +0,0 @@
/*
* Copyright 2022 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.feature.foryou
/**
* A sealed hierarchy for the user's current followed interests state.
*/
sealed interface FollowedInterestsUiState {
/**
* The current state is unknown (hasn't loaded yet)
*/
object Unknown : FollowedInterestsUiState
/**
* The user hasn't followed any interests yet.
*/
object None : FollowedInterestsUiState
/**
* The user has followed the given (non-empty) set of [topicIds] or [authorIds].
*/
data class FollowedInterests(
val topicIds: Set<String>,
val authorIds: Set<String>
) : FollowedInterestsUiState
}

@ -94,17 +94,17 @@ internal fun ForYouRoute(
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel()
) {
val interestsSelectionState by viewModel.interestsSelectionUiState.collectAsStateWithLifecycle()
val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
ForYouScreen(
isSyncing = isSyncing,
interestsSelectionState = interestsSelectionState,
onboardingUiState = onboardingUiState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::saveFollowedInterests,
saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier
)
@ -113,7 +113,7 @@ internal fun ForYouRoute(
@Composable
internal fun ForYouScreen(
isSyncing: Boolean,
interestsSelectionState: ForYouInterestsSelectionUiState,
onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
@ -125,11 +125,11 @@ internal fun ForYouScreen(
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// This code should be called when the UI is ready for use
// and relates to Time To Full Display.
val interestsLoaded =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading
val onboardingLoaded =
onboardingUiState !is OnboardingUiState.Loading
val feedLoaded = feedState !is NewsFeedUiState.Loading
if (interestsLoaded && feedLoaded) {
if (onboardingLoaded && feedLoaded) {
val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope.
@ -156,8 +156,8 @@ internal fun ForYouScreen(
.testTag("forYou:feed"),
state = state
) {
interestsSelection(
interestsSelectionState = interestsSelectionState,
onboarding(
onboardingUiState = onboardingUiState,
onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics,
@ -193,7 +193,7 @@ internal fun ForYouScreen(
AnimatedVisibility(
visible = isSyncing ||
feedState is NewsFeedUiState.Loading ||
interestsSelectionState is ForYouInterestsSelectionUiState.Loading
onboardingUiState is OnboardingUiState.Loading
) {
val loadingContentDescription = stringResource(id = R.string.for_you_loading)
Box(
@ -208,23 +208,23 @@ internal fun ForYouScreen(
}
/**
* An extension on [LazyListScope] defining the interests selection portion of the for you screen.
* Depending on the [interestsSelectionState], this might emit no items.
* An extension on [LazyListScope] defining the onboarding portion of the for you screen.
* Depending on the [onboardingUiState], this might emit no items.
*
*/
private fun LazyGridScope.interestsSelection(
interestsSelectionState: ForYouInterestsSelectionUiState,
private fun LazyGridScope.onboarding(
onboardingUiState: OnboardingUiState,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
interestsItemModifier: Modifier = Modifier
) {
when (interestsSelectionState) {
ForYouInterestsSelectionUiState.Loading,
ForYouInterestsSelectionUiState.LoadFailed,
ForYouInterestsSelectionUiState.NoInterestsSelection -> Unit
when (onboardingUiState) {
OnboardingUiState.Loading,
OnboardingUiState.LoadFailed,
OnboardingUiState.NotShown -> Unit
is ForYouInterestsSelectionUiState.WithInterestsSelection -> {
is OnboardingUiState.Shown -> {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(modifier = interestsItemModifier) {
Text(
@ -244,14 +244,14 @@ private fun LazyGridScope.interestsSelection(
style = MaterialTheme.typography.bodyMedium
)
AuthorsCarousel(
authors = interestsSelectionState.authors,
authors = onboardingUiState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
TopicSelection(
interestsSelectionState,
onboardingUiState,
onTopicCheckedChanged,
Modifier.padding(bottom = 8.dp)
)
@ -262,7 +262,7 @@ private fun LazyGridScope.interestsSelection(
) {
NiaFilledButton(
onClick = saveFollowedTopics,
enabled = interestsSelectionState.canSaveInterests,
enabled = onboardingUiState.isDismissable,
modifier = Modifier
.padding(horizontal = 40.dp)
.width(364.dp)
@ -280,7 +280,7 @@ private fun LazyGridScope.interestsSelection(
@Composable
private fun TopicSelection(
interestsSelectionState: ForYouInterestsSelectionUiState.WithInterestsSelection,
onboardingUiState: OnboardingUiState.Shown,
onTopicCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) = trace("TopicSelection") {
@ -306,7 +306,7 @@ private fun TopicSelection(
.heightIn(max = max(240.dp, with(LocalDensity.current) { 240.sp.toDp() }))
.fillMaxWidth()
) {
items(interestsSelectionState.topics) {
items(onboardingUiState.topics) {
SingleTopicButton(
name = it.topic.name,
topicId = it.topic.id,
@ -396,7 +396,7 @@ fun ForYouScreenPopulatedFeed() {
NiaTheme {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
@ -418,7 +418,7 @@ fun ForYouScreenOfflinePopulatedFeed() {
NiaTheme {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
@ -440,7 +440,7 @@ fun ForYouScreenTopicSelection() {
NiaTheme {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
onboardingUiState = OnboardingUiState.Shown(
topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) }
),
@ -465,7 +465,7 @@ fun ForYouScreenLoading() {
NiaTheme {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
@ -483,7 +483,7 @@ fun ForYouScreenPopulatedAndLoading() {
NiaTheme {
ForYouScreen(
isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)

@ -16,14 +16,9 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase
@ -31,9 +26,6 @@ import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResources
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.FollowedInterests
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.None
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.Unknown
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@ -41,56 +33,22 @@ 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.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel
class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository,
private val getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase,
getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase,
getFollowableTopicsStream: GetFollowableTopicsStreamUseCase,
savedStateHandle: SavedStateHandle
getFollowableTopicsStream: GetFollowableTopicsStreamUseCase
) : ViewModel() {
private val followedInterestsUiState: StateFlow<FollowedInterestsUiState> =
userDataRepository.userDataStream
.map { userData ->
if (userData.followedAuthors.isEmpty() && userData.followedTopics.isEmpty()) {
None
} else {
FollowedInterests(
authorIds = userData.followedAuthors,
topicIds = userData.followedTopics
)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = Unknown
)
/**
* The in-progress set of topics to be selected, persisted through process death with a
* [SavedStateHandle].
*/
private var inProgressTopicSelection by savedStateHandle.saveable {
mutableStateOf<Set<String>>(emptySet())
}
/**
* The in-progress set of authors to be selected, persisted through process death with a
* [SavedStateHandle].
*/
private var inProgressAuthorSelection by savedStateHandle.saveable {
mutableStateOf<Set<String>>(emptySet())
}
private val shouldShowOnboarding: Flow<Boolean> =
userDataRepository.userDataStream.map { !it.shouldHideOnboarding }
val isSyncing = syncStatusMonitor.isSyncing
.stateIn(
@ -100,35 +58,23 @@ class ForYouViewModel @Inject constructor(
)
val feedState: StateFlow<NewsFeedUiState> =
combine(
followedInterestsUiState,
snapshotFlow { inProgressTopicSelection },
snapshotFlow { inProgressAuthorSelection }
) { followedInterestsUiState, inProgressTopicSelection, inProgressAuthorSelection ->
when (followedInterestsUiState) {
// If we don't know the current selection state, emit loading.
Unknown -> flowOf<NewsFeedUiState>(NewsFeedUiState.Loading)
// If the user has followed topics, use those followed topics to populate the feed
is FollowedInterests -> {
userDataRepository.userDataStream
.map { userData ->
// If the user hasn't completed the onboarding and hasn't selected any interests
// show an empty news list to clearly demonstrate that their selections affect the
// news articles they will see.
if (!userData.shouldHideOnboarding &&
userData.followedAuthors.isEmpty() &&
userData.followedTopics.isEmpty()
) {
snapshotFlow { NewsFeedUiState.Success(emptyList()) }
} else {
getSaveableNewsResourcesStream(
filterTopicIds = followedInterestsUiState.topicIds,
filterAuthorIds = followedInterestsUiState.authorIds
filterTopicIds = userData.followedTopics,
filterAuthorIds = userData.followedAuthors
).mapToFeedState()
}
// If the user hasn't followed interests yet, show a realtime populated feed based
// on the in-progress interests selections, if there are any.
None -> {
if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
flowOf<NewsFeedUiState>(NewsFeedUiState.Success(emptyList()))
} else {
getSaveableNewsResourcesStream(
filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection
).mapToFeedState()
}
}
}
}
// Flatten the feed flows.
// As the selected topics and topic state changes, this will cancel the old feed
// monitoring and start the new one.
@ -139,58 +85,36 @@ class ForYouViewModel @Inject constructor(
initialValue = NewsFeedUiState.Loading
)
val interestsSelectionUiState: StateFlow<ForYouInterestsSelectionUiState> =
val onboardingUiState: StateFlow<OnboardingUiState> =
combine(
followedInterestsUiState,
getFollowableTopicsStream(
followedTopicIdsStream = snapshotFlow { inProgressTopicSelection }
),
snapshotFlow { inProgressAuthorSelection }.flatMapLatest {
getSortedFollowableAuthorsStream(it)
}
) { followedInterestsUiState, topics, authors ->
when (followedInterestsUiState) {
Unknown -> ForYouInterestsSelectionUiState.Loading
is FollowedInterests -> ForYouInterestsSelectionUiState.NoInterestsSelection
None -> {
if (topics.isEmpty() && authors.isEmpty()) {
ForYouInterestsSelectionUiState.LoadFailed
} else {
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = topics,
authors = authors
)
}
}
shouldShowOnboarding,
getFollowableTopicsStream(),
getSortedFollowableAuthorsStream()
) { shouldShowOnboarding, topics, authors ->
if (shouldShowOnboarding) {
OnboardingUiState.Shown(
topics = topics,
authors = authors
)
} else {
OnboardingUiState.NotShown
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ForYouInterestsSelectionUiState.Loading
initialValue = OnboardingUiState.Loading
)
fun updateTopicSelection(topicId: String, isChecked: Boolean) {
withMutableSnapshot {
inProgressTopicSelection =
// Update the in-progress selection based on whether the topic id was checked
if (isChecked) {
inProgressTopicSelection + topicId
} else {
inProgressTopicSelection - topicId
}
viewModelScope.launch {
userDataRepository.toggleFollowedTopicId(topicId, isChecked)
}
}
fun updateAuthorSelection(authorId: String, isChecked: Boolean) {
withMutableSnapshot {
inProgressAuthorSelection =
// Update the in-progress selection based on whether the author id was checked
if (isChecked) {
inProgressAuthorSelection + authorId
} else {
inProgressAuthorSelection - authorId
}
viewModelScope.launch {
userDataRepository.toggleFollowedAuthorId(authorId, isChecked)
}
}
@ -200,20 +124,9 @@ class ForYouViewModel @Inject constructor(
}
}
fun saveFollowedInterests() {
// Don't attempt to save anything if nothing is selected
if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
return
}
fun dismissOnboarding() {
viewModelScope.launch {
userDataRepository.setFollowedTopicIds(inProgressTopicSelection)
userDataRepository.setFollowedAuthorIds(inProgressAuthorSelection)
// Clear out the old selection, in case we return to onboarding
withMutableSnapshot {
inProgressTopicSelection = emptySet()
inProgressAuthorSelection = emptySet()
}
userDataRepository.setShouldHideOnboarding(true)
}
}
}

@ -20,35 +20,35 @@ import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
/**
* A sealed hierarchy describing the interests selection state for the for you screen.
* A sealed hierarchy describing the onboarding state for the for you screen.
*/
sealed interface ForYouInterestsSelectionUiState {
sealed interface OnboardingUiState {
/**
* The interests selection state is loading.
* The onboarding state is loading.
*/
object Loading : ForYouInterestsSelectionUiState
object Loading : OnboardingUiState
/**
* The interests selection state was unable to load.
* The onboarding state was unable to load.
*/
object LoadFailed : ForYouInterestsSelectionUiState
object LoadFailed : OnboardingUiState
/**
* There is no interests selection state.
* There is no onboarding state.
*/
object NoInterestsSelection : ForYouInterestsSelectionUiState
object NotShown : OnboardingUiState
/**
* There is a interests selection state, with the given lists of topics and authors.
* There is a onboarding state, with the given lists of topics and authors.
*/
data class WithInterestsSelection(
data class Shown(
val topics: List<FollowableTopic>,
val authors: List<FollowableAuthor>
) : ForYouInterestsSelectionUiState {
) : OnboardingUiState {
/**
* True if the current in-progress selection can be saved.
* True if the onboarding can be dismissed.
*/
val canSaveInterests: Boolean get() =
val isDismissable: Boolean get() =
topics.any { it.isFollowed } || authors.any { it.isFollowed }
}
}

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase
@ -65,7 +64,8 @@ class ForYouViewModelTest {
userDataRepository = userDataRepository
)
private val getSortedFollowableAuthorsStream = GetSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
private val getFollowableTopicsStreamUseCase = GetFollowableTopicsStreamUseCase(
topicsRepository = topicsRepository,
@ -80,16 +80,15 @@ class ForYouViewModelTest {
userDataRepository = userDataRepository,
getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase,
getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream,
getFollowableTopicsStream = getFollowableTopicsStreamUseCase,
savedStateHandle = SavedStateHandle()
getFollowableTopicsStream = getFollowableTopicsStreamUseCase
)
}
@Test
fun stateIsInitiallyLoading() = runTest {
assertEquals(
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionUiState.value
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
}
@ -97,14 +96,14 @@ class ForYouViewModelTest {
@Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
assertEquals(
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionUiState.value
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
@ -130,14 +129,14 @@ class ForYouViewModelTest {
@Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors)
assertEquals(
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionUiState.value
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
@ -146,16 +145,16 @@ class ForYouViewModelTest {
}
@Test
fun stateIsLoadingWhenTopicsAreLoading() = runTest {
fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionUiState.value
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
@ -164,16 +163,16 @@ class ForYouViewModelTest {
}
@Test
fun stateIsLoadingWhenAuthorsAreLoading() = runTest {
fun onboardingStateIsLoadingWhenAuthorsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals(
ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionUiState.value
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
@ -182,9 +181,9 @@ class ForYouViewModelTest {
}
@Test
fun stateIsInterestsSelectionWhenNewsResourcesAreLoading() = runTest {
fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
@ -193,7 +192,7 @@ class ForYouViewModelTest {
userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -265,7 +264,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -279,9 +278,9 @@ class ForYouViewModelTest {
}
@Test
fun stateIsInterestsSelectionAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest {
fun onboardingIsShownAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
@ -291,7 +290,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -363,7 +362,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -378,27 +377,28 @@ class ForYouViewModelTest {
}
@Test
fun stateIsWithoutInterestsSelectionAfterLoadingFollowedTopics() = runTest {
fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("0", "1"))
viewModel.dismissOnboarding()
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
OnboardingUiState.NotShown,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
OnboardingUiState.NotShown,
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -417,52 +417,10 @@ class ForYouViewModelTest {
collectJob2.cancel()
}
@Test
fun stateIsWithoutInterestsSelectionAfterLoadingFollowedAuthors() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(setOf("0", "1"))
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
)
assertEquals(
NewsFeedUiState.Loading,
viewModel.feedState.value
)
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = sampleNewsResources.map {
SaveableNewsResource(
newsResource = it,
isSaved = false
)
}
),
viewModel.feedState.value
)
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
@ -472,7 +430,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -544,7 +502,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -556,7 +514,7 @@ class ForYouViewModelTest {
viewModel.updateTopicSelection("1", isChecked = true)
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -628,7 +586,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -651,9 +609,9 @@ class ForYouViewModelTest {
}
@Test
fun topicSelectionUpdatesAfterSelectingAuthor() = runTest {
fun authorSelectionUpdatesAfterSelectingAuthor() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
@ -663,7 +621,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -735,7 +693,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -747,7 +705,7 @@ class ForYouViewModelTest {
viewModel.updateAuthorSelection("1", isChecked = true)
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -819,7 +777,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -844,7 +802,7 @@ class ForYouViewModelTest {
@Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
@ -857,7 +815,7 @@ class ForYouViewModelTest {
advanceUntilIdle()
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -929,7 +887,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -943,9 +901,9 @@ class ForYouViewModelTest {
}
@Test
fun topicSelectionUpdatesAfterUnselectingAuthor() = runTest {
fun authorSelectionUpdatesAfterUnselectingAuthor() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
@ -958,7 +916,7 @@ class ForYouViewModelTest {
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
@ -1030,331 +988,7 @@ class ForYouViewModelTest {
)
),
),
viewModel.interestsSelectionUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
)
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSavingTopicsOnly() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateTopicSelection("1", isChecked = true)
viewModel.saveFollowedInterests()
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false,
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false,
)
)
),
viewModel.feedState.value
)
assertEquals(setOf("1"), userDataRepository.getCurrentFollowedTopics())
assertEquals(emptySet<Int>(), userDataRepository.getCurrentFollowedAuthors())
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSavingAuthorsOnly() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateAuthorSelection("0", isChecked = true)
viewModel.saveFollowedInterests()
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[0],
isSaved = false
),
)
),
viewModel.feedState.value
)
assertEquals(emptySet<Int>(), userDataRepository.getCurrentFollowedTopics())
assertEquals(setOf("0"), userDataRepository.getCurrentFollowedAuthors())
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun topicSelectionUpdatesAfterSavingAuthorsAndTopics() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateAuthorSelection("1", isChecked = true)
viewModel.updateTopicSelection("1", isChecked = true)
viewModel.saveFollowedInterests()
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
)
),
viewModel.feedState.value
)
assertEquals(setOf("1"), userDataRepository.getCurrentFollowedTopics())
assertEquals(setOf("1"), userDataRepository.getCurrentFollowedAuthors())
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun topicSelectionIsResetAfterSavingTopicsAndRemovingThem() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateTopicSelection("1", isChecked = true)
viewModel.saveFollowedInterests()
userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
)
),
viewModel.interestsSelectionUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
)
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun authorSelectionIsResetAfterSavingAuthorsAndRemovingThem() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateAuthorSelection("1", isChecked = true)
viewModel.saveFollowedInterests()
userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals(
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
)
),
viewModel.interestsSelectionUiState.value
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
@ -1370,19 +1004,20 @@ class ForYouViewModelTest {
@Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("1"))
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setShouldHideOnboarding(true)
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved("2", true)
assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionUiState.value
OnboardingUiState.NotShown,
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(

@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetPersistentSortedFollowableAuthorsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
class InterestsViewModel @Inject constructor(
val userDataRepository: UserDataRepository,
getFollowableTopicsStream: GetFollowableTopicsStreamUseCase,
getPersistentSortedFollowableAuthorsStream: GetPersistentSortedFollowableAuthorsStreamUseCase
getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase
) : ViewModel() {
private val _tabState = MutableStateFlow(
@ -51,7 +51,7 @@ class InterestsViewModel @Inject constructor(
val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> = combine(
getPersistentSortedFollowableAuthorsStream(),
getSortedFollowableAuthorsStream(),
getFollowableTopicsStream(sortBy = TopicSortField.NAME),
InterestsUiState::Interests
).stateIn(

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.interests
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetPersistentSortedFollowableAuthorsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Author
@ -53,8 +53,8 @@ class InterestsViewModelTest {
topicsRepository = topicsRepository,
userDataRepository = userDataRepository
)
private val getPersistentSortedFollowableAuthorsStream =
GetPersistentSortedFollowableAuthorsStreamUseCase(
private val getSortedFollowableAuthorsStream =
GetSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
@ -65,7 +65,7 @@ class InterestsViewModelTest {
viewModel = InterestsViewModel(
userDataRepository = userDataRepository,
getFollowableTopicsStream = getFollowableTopicsStreamUseCase,
getPersistentSortedFollowableAuthorsStream = getPersistentSortedFollowableAuthorsStream
getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream
)
}

@ -10,7 +10,7 @@ androidxComposeRuntimeTracing = "1.0.0-alpha01"
androidxCore = "1.9.0"
androidxCoreSplashscreen = "1.0.0"
androidxDataStore = "1.0.0"
androidxEspresso = "3.4.0"
androidxEspresso = "3.5.0"
androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.0-alpha03"
androidxMacroBenchmark = "1.1.0"
@ -19,10 +19,10 @@ androidxMetrics = "1.0.0-alpha03"
androidxProfileinstaller = "1.2.0"
androidxStartup = "1.1.1"
androidxWindowManager = "1.0.0"
androidxTestCore = "1.5.0-rc01"
androidxTestExt = "1.1.3"
androidxTestRunner = "1.4.0"
androidxTestRules = "1.4.0"
androidxTestCore = "1.5.0"
androidxTestExt = "1.1.4"
androidxTestRunner = "1.5.1"
androidxTestRules = "1.5.0"
androidxTracing = "1.1.0"
androidxUiAutomator = "2.2.0"
androidxWork = "2.7.1"
@ -128,4 +128,4 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }

Loading…
Cancel
Save