Merge remote-tracking branch 'origin/feature/robot-ui-tests' into feature/robot-ui-tests

# Conflicts:
#	feature/settings/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogRobot.kt
#	feature/settings/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt
#	feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicRobot.kt
#	feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt
pull/402/head
JanFidor 3 years ago
commit 3735bc3dd3

@ -34,16 +34,16 @@ jobs:
uses: gradle/gradle-build-action@v2
- name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
- name: Check lint
run: ./gradlew lintDemoDebug --stacktrace
run: ./gradlew lintDemoDebug
- name: Build all build type and flavor permutations
run: ./gradlew assemble --stacktrace
run: ./gradlew assemble
- name: Run local tests
run: ./gradlew testDemoDebug testProdDebug --stacktrace
run: ./gradlew testDemoDebug testProdDebug
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v3
@ -90,7 +90,7 @@ jobs:
disable-animations: true
disk-size: 6000M
heap-size: 600M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest --stacktrace
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest
- name: Upload test reports
if: always()

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<component name="ProjectRunConfigurationManager">
<!--
Baseline Profiles improve code execution speed by around 30% from the first launch by avoiding interpretation and just-in-time (JIT) compilation steps for included code paths.
More information at http://d.android.com/baseline-profiles.
-->
<configuration default="false" name="Generate Demo Baseline Profile" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<!-- TODO Once we use Gradle wrapper 7.6, we can use rerun instead of rerun-tasks that will skip cache only for one task -->
<option name="scriptParameters" value="--rerun-tasks -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":benchmark:pixel6Api31DemoBenchmarkAndroidTest" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

@ -1,19 +1,21 @@
![Now in Android](docs/images/nia-splash.jpg "Now in Android")
Now in Android App [Work in progress 🚧]
<a href="https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" height="70"></a>
Now in Android App
==================
**Learn how this app was designed and built in the [design case study](https://goo.gle/nia-figma), [architecture learning journey](docs/ArchitectureLearningJourney.md) and [modularization learning journey](docs/ModularizationLearningJourney.md).**
This is the repository for the [Now in Android](https://developer.android.com/series/now-in-android)
app.
app. It is a **work in progress** 🚧.
**Now in Android** is a fully functional Android app built entirely with Kotlin and Jetpack Compose. It
follows Android design and development best practices and is intended to be a useful reference
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"

@ -32,8 +32,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

@ -15,12 +15,6 @@
limitations under the License.
-->
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<!-- Status bar -->
<color name="black30">#4D000000</color>
</resources>

@ -55,9 +55,9 @@ android {
testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel5Api30") {
device = "Pixel 5"
apiLevel = 30
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}

@ -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 {

@ -0,0 +1,66 @@
/*
* 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.author
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
internal class AuthorRobot(
private val composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>
) {
fun setContent(authorUiState: AuthorUiState, newsUiState: NewsUiState) {
composeTestRule.setContent {
AuthorScreen(
authorUiState = authorUiState,
newsUiState = newsUiState,
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
}
fun loadingIndicatorExists() {
val authorLoading = composeTestRule.activity.getString(R.string.author_loading)
composeTestRule
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
fun authorExists(author: FollowableAuthor) {
composeTestRule
.onNodeWithText(author.author.name)
.assertExists()
composeTestRule
.onNodeWithText(author.author.bio)
.assertExists()
}
fun newsResourceExists(newsResource: NewsResource) {
composeTestRule
.onNodeWithText(newsResource.title)
.assertExists()
}
}

@ -17,16 +17,15 @@
package com.google.samples.apps.nowinandroid.feature.author
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -40,108 +39,78 @@ class AuthorScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var authorLoading: String
@Before
fun setup() {
composeTestRule.activity.apply {
authorLoading = getString(R.string.author_loading)
}
}
@Test
fun niaLoadingWheel_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
launchAuthorRobot(
composeTestRule, AuthorUiState.Loading, NewsUiState.Loading,
) {
loadingIndicatorExists()
}
composeTestRule
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
@Test
fun authorTitle_whenAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Success(testAuthor),
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
launchAuthorRobot(
composeTestRule,
AuthorUiState.Success(testAuthor),
NewsUiState.Loading
) {
authorExists(testAuthor)
}
// Name is shown
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
// Bio is shown
composeTestRule
.onNodeWithText(testAuthor.author.bio)
.assertExists()
}
@Test
fun news_whenAuthorIsLoading_isNotShown() {
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
val newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
)
launchAuthorRobot(
composeTestRule,
AuthorUiState.Loading,
newsUiState
) {
loadingIndicatorExists()
}
// Loading indicator shown
composeTestRule
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
@Test
fun news_whenSuccessAndAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Success(testAuthor),
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
val newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
)
launchAuthorRobot(
composeTestRule,
AuthorUiState.Success(testAuthor),
newsUiState
) {
newsResourceExists(sampleNewsResources.first())
}
// First news title shown
composeTestRule
.onNodeWithText(sampleNewsResources.first().title)
.assertExists()
}
}
private fun launchAuthorRobot(
composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
authorUiState: AuthorUiState,
newsUiState: NewsUiState,
func: AuthorRobot.() -> Unit
) = AuthorRobot(composeTestRule).apply {
setContent(authorUiState, newsUiState)
func()
}
private const val AUTHOR_1_NAME = "Author 1"
private const val AUTHOR_2_NAME = "Author 2"
private const val AUTHOR_3_NAME = "Author 3"

@ -0,0 +1,87 @@
/*
* 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.bookmarks
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
internal class BookmarksRobot(
private val composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>
) {
private val removedBookmarks = mutableSetOf<String>()
fun setContent(newsFeedUiState: NewsFeedUiState) {
composeTestRule.setContent {
BookmarksScreen(feedState = newsFeedUiState, removeFromBookmarks = {
removedBookmarks.add(it)
})
}
}
fun loadingIndicatorShown() {
composeTestRule.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.saved_loading)
).assertExists()
}
fun clickableNewsResourceExists(newsResource: NewsResource) {
composeTestRule.onNodeWithText(
newsResource.title, substring = true
).assertExists().assertHasClickAction()
}
fun scrollToNewsResource(newsResource: NewsResource) {
composeTestRule.onNode(hasScrollToNodeAction()).performScrollToNode(
hasText(
newsResource.title, substring = true
)
)
}
fun clickNewsResourceBookmark(newsResource: NewsResource) {
composeTestRule.onAllNodesWithContentDescription(
composeTestRule.activity.getString(
com.google.samples.apps.nowinandroid.core.ui.R.string.unbookmark
)
).filter(
hasAnyAncestor(
hasText(
newsResource.title, substring = true
)
)
).assertCountEquals(1).onFirst().performClick()
}
fun removedNewsResourceBookmark(newsResource: NewsResource) =
removedBookmarks.contains(newsResource.id)
}

@ -17,25 +17,12 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.activity.ComponentActivity
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@ -49,93 +36,50 @@ class BookmarksScreenTest {
@Test
fun loading_showsLoadingSpinner() {
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Loading,
removeFromBookmarks = { }
)
launchBookmarksRobot(
composeTestRule,
NewsFeedUiState.Loading
) {
loadingIndicatorShown()
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.saved_loading)
)
.assertExists()
}
@Test
fun feed_whenHasBookmarks_showsBookmarks() {
lateinit var windowSizeClass: WindowSizeClass
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
),
removeFromBookmarks = { }
launchBookmarksRobot(
composeTestRule,
NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
)
) {
clickNewsResourceBookmark(previewNewsResources[0])
scrollToNewsResource(previewNewsResources[1])
clickableNewsResourceExists(previewNewsResources[1])
}
composeTestRule
.onNodeWithText(
previewNewsResources[0].title,
substring = true
)
.assertExists()
.assertHasClickAction()
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
previewNewsResources[1].title,
substring = true
)
.assertExists()
.assertHasClickAction()
}
@Test
fun feed_whenRemovingBookmark_removesBookmark() {
var removeFromBookmarksCalled = false
composeTestRule.setContent {
BookmarksScreen(
feedState = NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
),
removeFromBookmarks = { newsResourceId ->
assertEquals(previewNewsResources[0].id, newsResourceId)
removeFromBookmarksCalled = true
}
launchBookmarksRobot(
composeTestRule,
NewsFeedUiState.Success(
previewNewsResources.take(2)
.map { SaveableNewsResource(it, true) }
)
) {
clickNewsResourceBookmark(previewNewsResources[0])
removedNewsResourceBookmark(previewNewsResources[0])
}
composeTestRule
.onAllNodesWithContentDescription(
composeTestRule.activity.getString(
com.google.samples.apps.nowinandroid.core.ui.R.string.unbookmark
)
).filter(
hasAnyAncestor(
hasText(
previewNewsResources[0].title,
substring = true
)
)
)
.assertCountEquals(1)
.onFirst()
.performClick()
assertTrue(removeFromBookmarksCalled)
}
}
private fun launchBookmarksRobot(
composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
newsFeedUiState: NewsFeedUiState,
func: BookmarksRobot.() -> Unit
) = BookmarksRobot(composeTestRule).apply {
setContent(newsFeedUiState)
func()
}

@ -0,0 +1,124 @@
/*
* 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
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.rules.ActivityScenarioRule
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.NewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.R.string
internal class ForYouRobot(
private val composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>
) {
private val doneButtonMatcher by lazy {
hasText(composeTestRule.activity.resources.getString(string.done))
}
fun setContent(
isSyncing: Boolean,
onboardingState: OnboardingUiState,
feedState: NewsFeedUiState,
) {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = isSyncing,
onboardingUiState = onboardingState,
feedState = feedState,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
}
fun loadingIndicatorExists() {
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(string.for_you_loading)
)
.assertExists()
}
fun clickableAuthorExists(author: FollowableAuthor) {
composeTestRule
.onNodeWithText(author.author.name)
.assertExists()
.assertHasClickAction()
}
fun clickableTopicExists(topic: FollowableTopic) {
composeTestRule
.onNodeWithText(topic.topic.name)
.assertExists()
.assertHasClickAction()
}
fun scrollToDoneButton() {
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(doneButtonMatcher)
}
fun clickableDoneButtonExists(isEnabled: Boolean) {
composeTestRule
.onNode(doneButtonMatcher)
.assertExists()
.assertHasClickAction()
.apply {
if (isEnabled) assertIsEnabled() else assertIsNotEnabled()
}
}
fun clickableNewsResourceExists(newsResource: NewsResource) {
composeTestRule
.onNodeWithText(
newsResource.title,
substring = true
)
.assertExists()
.assertHasClickAction()
}
fun scrollToNewsResource(newsResource: NewsResource) {
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
newsResource.title,
substring = true
)
)
}
}

@ -17,17 +17,9 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.rules.ActivityScenarioRule
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.domain.model.SaveableNewsResource
@ -42,305 +34,168 @@ class ForYouScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val doneButtonMatcher by lazy {
hasText(
composeTestRule.activity.resources.getString(R.string.done)
)
}
@Test
fun circularProgressIndicator_whenScreenIsLoading_exists() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = false,
onboardingState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
) {
loadingIndicatorExists()
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
.assertExists()
}
@Test
fun circularProgressIndicator_whenScreenIsSyncing_exists() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = true,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = true,
onboardingState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()),
) {
loadingIndicatorExists()
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
.assertExists()
}
@Test
fun topicSelector_whenNoTopicsSelected_showsAuthorAndTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics,
authors = testAuthors
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = false,
onboardingState = OnboardingUiState.Shown(
topics = testTopics,
authors = testAuthors
),
feedState = NewsFeedUiState.Success(emptyList()),
) {
testAuthors.forEach { author ->
clickableAuthorExists(author)
}
}
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { topic ->
clickableTopicExists(topic)
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertExists()
.assertHasClickAction()
scrollToDoneButton()
clickableDoneButtonExists(false)
}
// Scroll until the Done button is visible
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(doneButtonMatcher)
composeTestRule
.onNode(doneButtonMatcher)
.assertExists()
.assertIsNotEnabled()
.assertHasClickAction()
}
@Test
fun topicSelector_whenSomeTopicsSelected_showsAuthorAndTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic
topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
},
authors = testAuthors
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = false,
onboardingState =
OnboardingUiState.Shown(
// Follow one topic
topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
},
authors = testAuthors
),
feedState = NewsFeedUiState.Success(emptyList()),
) {
testAuthors.forEach { author ->
clickableAuthorExists(author)
}
}
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { topic ->
clickableTopicExists(topic)
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertExists()
.assertHasClickAction()
scrollToDoneButton()
clickableDoneButtonExists(true)
}
// Scroll until the Done button is visible
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(doneButtonMatcher)
composeTestRule
.onNode(doneButtonMatcher)
.assertExists()
.assertIsEnabled()
.assertHasClickAction()
}
@Test
fun topicSelector_whenSomeAuthorsSelected_showsAuthorAndTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
// Follow one topic
topics = testTopics,
authors = testAuthors.mapIndexed { index, testAuthor ->
testAuthor.copy(isFollowed = index == 1)
}
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = false,
onboardingState = OnboardingUiState.Shown(
// Follow one topic
topics = testTopics,
authors = testAuthors.mapIndexed { index, testAuthor ->
testAuthor.copy(isFollowed = index == 1)
}
),
feedState = NewsFeedUiState.Success(emptyList()),
) {
testAuthors.forEach { author ->
clickableAuthorExists(author)
}
}
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { topic ->
clickableTopicExists(topic)
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertExists()
.assertHasClickAction()
scrollToDoneButton()
clickableDoneButtonExists(true)
}
// Scroll until the Done button is visible
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(doneButtonMatcher)
composeTestRule
.onNode(doneButtonMatcher)
.assertExists()
.assertIsEnabled()
.assertHasClickAction()
}
@Test
fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = testTopics,
authors = testAuthors
),
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = false,
onboardingState =
OnboardingUiState.Shown(
topics = testTopics,
authors = testAuthors
),
feedState = NewsFeedUiState.Loading,
) {
loadingIndicatorExists()
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
.assertExists()
}
@Test
fun feed_whenNoInterestsSelectionAndLoading_showsLoadingIndicator() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = false,
onboardingState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading,
) {
loadingIndicatorExists()
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
.assertExists()
}
@Test
fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {
composeTestRule.setContent {
ForYouScreen(
isSyncing = false,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
launchForYouRobot(
composeTestRule = composeTestRule,
isSyncing = false,
onboardingState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
) {
clickableNewsResourceExists(previewNewsResources[0])
scrollToNewsResource(previewNewsResources[1])
clickableNewsResourceExists(previewNewsResources[1])
}
composeTestRule
.onNodeWithText(
previewNewsResources[0].title,
substring = true
)
.assertExists()
.assertHasClickAction()
composeTestRule.onNode(hasScrollToNodeAction())
.performScrollToNode(
hasText(
previewNewsResources[1].title,
substring = true
)
)
composeTestRule
.onNodeWithText(
previewNewsResources[1].title,
substring = true
)
.assertExists()
.assertHasClickAction()
}
}
private fun launchForYouRobot(
composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
isSyncing: Boolean,
onboardingState: OnboardingUiState,
feedState: NewsFeedUiState,
func: ForYouRobot.() -> Unit
) = ForYouRobot(composeTestRule).apply {
setContent(isSyncing, onboardingState, feedState)
func()
}
private val testTopic = Topic(
id = "",
name = "",

@ -43,10 +43,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
@ -54,7 +56,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
@ -73,8 +74,8 @@ fun AuthorsCarousel(
LazyRow(
modifier = modifier.testTag(tag),
contentPadding = PaddingValues(24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
state = lazyListState
) {
items(items = authors, key = { item -> item.author.id }) { followableAuthor ->
@ -97,24 +98,32 @@ fun AuthorItem(
modifier: Modifier = Modifier,
) {
val followDescription = if (following) {
stringResource(id = R.string.following)
stringResource(R.string.following)
} else {
stringResource(id = R.string.not_following)
stringResource(R.string.not_following)
}
val followActionLabel = if (following) {
stringResource(R.string.unfollow)
} else {
stringResource(R.string.follow)
}
Column(
modifier = modifier
.toggleable(
value = following,
enabled = true,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onValueChange = { newFollowing -> onAuthorClick(newFollowing) },
)
.padding(8.dp)
.sizeIn(maxWidth = 48.dp)
.semantics(mergeDescendants = true) {
// Add information for A11y services, explaining what each state means and
// what will happen when the user interacts with the author item.
stateDescription = "$followDescription ${author.name}"
onClick(label = followActionLabel, action = null)
}
) {
Box(
@ -136,28 +145,27 @@ fun AuthorItem(
AsyncImage(
modifier = authorImageModifier,
model = author.imageUrl,
contentScale = ContentScale.Fit,
contentScale = ContentScale.Crop,
contentDescription = null
)
}
NiaToggleButton(
checked = following,
onCheckedChange = onAuthorClick,
modifier = Modifier.align(Alignment.BottomEnd),
icon = {
Icon(
imageVector = NiaIcons.Add,
contentDescription = null
)
},
checkedIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = null
)
},
size = 24.dp,
backgroundColor = MaterialTheme.colorScheme.surface
val backgroundColor =
if (following)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
Icon(
imageVector = if (following) NiaIcons.Check else NiaIcons.Add,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(18.dp)
.drawBehind {
drawCircle(
color = backgroundColor,
radius = 12.dp.toPx()
)
}
)
}
Spacer(modifier = Modifier.height(4.dp))

@ -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 }
}
}

@ -27,5 +27,7 @@
<!-- Authors-->
<string name="following">You are following</string>
<string name="not_following">You are not following</string>
<string name="follow">Follow</string>
<string name="unfollow">Unfollow</string>
</resources>

@ -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(

@ -0,0 +1,102 @@
/*
* 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.interests
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen
import com.google.samples.apps.nowinandroid.feature.interests.InterestsTabState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.R.string
internal class InterestsRobot(
private val composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>
) {
private val interestsLoading = getString(string.interests_loading)
private val interestsEmptyHeader = getString(string.interests_empty_header)
private val interestsTopicCardFollowButton =
getString(string.interests_card_follow_button_content_desc)
private val interestsTopicCardUnfollowButton =
getString(string.interests_card_unfollow_button_content_desc)
fun setContent(uiState: InterestsUiState, tabIndex: Int = 0) {
composeTestRule.setContent {
InterestsScreen(
uiState = uiState,
tabState = InterestsTabState(
titles = listOf(string.interests_topics, string.interests_people),
currentIndex = tabIndex
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {},
)
}
}
fun interestsLoadingExists() {
composeTestRule
.onNodeWithContentDescription(interestsLoading)
.assertExists()
}
fun nodeWithTextDisplayed(text: String) {
composeTestRule
.onNodeWithText(text)
.assertIsDisplayed()
}
fun nodesWithTextCountEquals(text: String, count: Int) {
composeTestRule
.onAllNodesWithText(text)
.assertCountEquals(count)
}
fun interestsTopicCardFollowButtonCountEquals(count: Int) {
nodesWithContentDescriptionCountEquals(interestsTopicCardFollowButton, count)
}
fun interestsTopicCardUnfollowButtonCountEquals(count: Int) {
nodesWithContentDescriptionCountEquals(interestsTopicCardUnfollowButton, count)
}
private fun nodesWithContentDescriptionCountEquals(text: String, count: Int) {
composeTestRule
.onAllNodesWithContentDescription(text)
.assertCountEquals(count)
}
fun interestsEmptyHeaderDisplayed() {
composeTestRule
.onNodeWithText(interestsEmptyHeader)
.assertIsDisplayed()
}
private fun getString(@StringRes stringId: Int) =
composeTestRule.activity.resources.getString(stringId)
}

@ -17,23 +17,14 @@
package com.google.samples.apps.nowinandroid.interests
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.rules.ActivityScenarioRule
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
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen
import com.google.samples.apps.nowinandroid.feature.interests.InterestsTabState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.R
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -47,142 +38,94 @@ class InterestsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var interestsLoading: String
private lateinit var interestsEmptyHeader: String
private lateinit var interestsTopicCardFollowButton: String
private lateinit var interestsTopicCardUnfollowButton: String
@Before
fun setup() {
composeTestRule.activity.apply {
interestsLoading = getString(R.string.interests_loading)
interestsEmptyHeader = getString(R.string.interests_empty_header)
interestsTopicCardFollowButton =
getString(R.string.interests_card_follow_button_content_desc)
interestsTopicCardUnfollowButton =
getString(R.string.interests_card_unfollow_button_content_desc)
}
}
@Test
fun niaLoadingWheel_inTopics_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Loading, tabIndex = 0)
launchBookmarksRobot(
composeTestRule,
InterestsUiState.Loading,
0
) {
interestsLoadingExists()
}
composeTestRule
.onNodeWithContentDescription(interestsLoading)
.assertExists()
}
@Test
fun niaLoadingWheel_inAuthors_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Loading, tabIndex = 1)
launchBookmarksRobot(
composeTestRule,
InterestsUiState.Loading,
1
) {
interestsLoadingExists()
}
composeTestRule
.onNodeWithContentDescription(interestsLoading)
.assertExists()
}
@Test
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = testTopics, authors = listOf()),
tabIndex = 0
)
launchBookmarksRobot(
composeTestRule,
InterestsUiState.Interests(topics = testTopics, authors = listOf()),
0
) {
nodeWithTextDisplayed(TOPIC_1_NAME)
nodeWithTextDisplayed(TOPIC_2_NAME)
nodeWithTextDisplayed(TOPIC_3_NAME)
nodesWithTextCountEquals(TOPIC_SHORT_DESC, testTopics.count())
interestsTopicCardFollowButtonCountEquals(numberOfUnfollowedTopics)
interestsTopicCardUnfollowButtonCountEquals(numberOfFollowedTopics)
}
composeTestRule
.onNodeWithText(TOPIC_1_NAME)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(TOPIC_2_NAME)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(TOPIC_3_NAME)
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(TOPIC_SHORT_DESC)
.assertCountEquals(testTopics.count())
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardFollowButton)
.assertCountEquals(numberOfUnfollowedTopics)
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardUnfollowButton)
.assertCountEquals(testAuthors.filter { it.isFollowed }.size)
}
@Test
fun interestsWithTopics_whenAuthorsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = listOf(), authors = testAuthors),
tabIndex = 1
)
launchBookmarksRobot(
composeTestRule,
InterestsUiState.Interests(topics = listOf(), authors = testAuthors),
1
) {
nodeWithTextDisplayed("Android Dev")
nodeWithTextDisplayed("Android Dev 2")
nodeWithTextDisplayed("Android Dev 3")
interestsTopicCardFollowButtonCountEquals(numberOfUnfollowedAuthors)
interestsTopicCardUnfollowButtonCountEquals(numberOfFollowedAuthors)
}
composeTestRule
.onNodeWithText("Android Dev")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Android Dev 2")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Android Dev 3")
.assertIsDisplayed()
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardFollowButton)
.assertCountEquals(numberOfUnfollowedAuthors)
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardUnfollowButton)
.assertCountEquals(testTopics.filter { it.isFollowed }.size)
}
@Test
fun topicsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Empty, tabIndex = 0)
launchBookmarksRobot(
composeTestRule,
InterestsUiState.Empty,
0
) {
interestsEmptyHeaderDisplayed()
}
composeTestRule
.onNodeWithText(interestsEmptyHeader)
.assertIsDisplayed()
}
@Test
fun authorsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Empty, tabIndex = 1)
launchBookmarksRobot(
composeTestRule,
InterestsUiState.Empty,
1
) {
interestsEmptyHeaderDisplayed()
}
composeTestRule
.onNodeWithText(interestsEmptyHeader)
.assertIsDisplayed()
}
}
@Composable
private fun InterestsScreen(uiState: InterestsUiState, tabIndex: Int = 0) {
InterestsScreen(
uiState = uiState,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = tabIndex
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {},
)
}
private fun launchBookmarksRobot(
composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
uiState: InterestsUiState,
tabIndex: Int,
func: InterestsRobot.() -> Unit
) = InterestsRobot(composeTestRule).apply {
setContent(uiState, tabIndex)
func()
}
private const val TOPIC_1_NAME = "Headlines"
@ -266,4 +209,7 @@ private val testAuthors = listOf(
)
private val numberOfUnfollowedTopics = testTopics.filter { !it.isFollowed }.size
private val numberOfFollowedTopics = testTopics.filter { it.isFollowed }.size
private val numberOfUnfollowedAuthors = testAuthors.filter { !it.isFollowed }.size
private val numberOfFollowedAuthors = testAuthors.filter { it.isFollowed }.size

@ -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
)
}

@ -1,17 +1,17 @@
/*
* 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
* 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
* 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.
* 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.settings
@ -24,7 +24,8 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.rules.ActivityScenarioRule
internal class SettingsDialogRobot(
private val composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>
private val composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>
) {
fun setContent(settingsUiState: SettingsUiState) {
composeTestRule.setContent {
@ -57,4 +58,4 @@ internal class SettingsDialogRobot(
fun getString(@StringRes stringId: Int) =
composeTestRule.activity.resources.getString(stringId)
}
}

@ -82,7 +82,8 @@ class SettingsDialogTest {
}
private fun launchSettingsDialogRobot(
composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
settingsUiState: SettingsUiState,
func: SettingsDialogRobot.() -> Unit
) = SettingsDialogRobot(composeTestRule).apply {

@ -1,17 +1,17 @@
/*
* 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
* 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
* 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.
* 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.topic
@ -29,7 +29,8 @@ import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
internal class TopicRobot(
private val composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>
private val composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>
) {
private val topicLoading = composeTestRule.activity.resources.getString(R.string.topic_loading)
@ -67,4 +68,4 @@ internal class TopicRobot(
.onFirst()
.performScrollToNode(hasText(newsResource.title))
}
}
}

@ -103,7 +103,8 @@ class TopicScreenTest {
}
private fun launchTopicRobot(
composeTestRule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
composeTestRule: AndroidComposeTestRule<
ActivityScenarioRule<ComponentActivity>, ComponentActivity>,
topicUiState: TopicUiState,
newsUiState: NewsUiState,
func: TopicRobot.() -> Unit

@ -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" }

@ -1,3 +1,3 @@
# :sync module
![Dependency graph](../docs/images/graphs/dep_graph_lint.png)
![Dependency graph](../docs/images/graphs/dep_graph_sync.png)

Loading…
Cancel
Save