Add domain layer. See go/nia-domain-layer for details.

Change-Id: I3f4684005e81fb9c4163bf59c7026dcff6e88dc4
pull/1516/head
Don Turner 2 years ago
parent 55d22c1a01
commit 4a4de7d6a4

@ -45,6 +45,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", project(":core:data")) add("implementation", project(":core:data"))
add("implementation", project(":core:common")) add("implementation", project(":core:common"))
add("implementation", project(":core:navigation")) add("implementation", project(":core:navigation"))
add("implementation", project(":core:domain"))
add("testImplementation", project(":core:testing")) add("testImplementation", project(":core:testing"))
add("androidTestImplementation", project(":core:testing")) add("androidTestImplementation", project(":core:testing"))

@ -0,0 +1 @@
/build

@ -0,0 +1,34 @@
/*
* 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.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
}
dependencies {
implementation(project(":core:data"))
implementation(project(":core:model"))
testImplementation(project(":core:testing"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
}

@ -0,0 +1,17 @@
<?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.
-->
<manifest package="com.google.samples.apps.nowinandroid.core.domain" />

@ -0,0 +1,76 @@
/*
* 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.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
/**
* A use case which obtains a list of topics with their followed state.
*/
class GetFollowableTopicsStreamUseCase @Inject constructor(
private val topicsRepository: TopicsRepository,
private val userDataRepository: UserDataRepository
) {
/**
* 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>> {
return combine(
followedTopicIdsStream,
topicsRepository.getTopicsStream()
) { followedIds, topics ->
val followedTopics = topics
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in followedIds
)
}
if (sortBy == NAME) {
followedTopics.sortedBy { it.topic.name }
} else {
followedTopics
}
}
}
}
enum class TopicSortField {
NONE,
NAME,
}

@ -0,0 +1,48 @@
/*
* 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)
}
}
}

@ -0,0 +1,77 @@
/*
* 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.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
/**
* A use case responsible for obtaining news resources with their associated bookmarked (also known
* as "saved") state.
*/
class GetSaveableNewsResourcesStreamUseCase @Inject constructor(
private val newsRepository: NewsRepository,
userDataRepository: UserDataRepository
) {
private val bookmarkedNewsResourcesStream = userDataRepository.userDataStream.map {
it.bookmarkedNewsResources
}
/**
* Returns a list of SaveableNewsResources which match the supplied set of topic ids or author
* ids.
*
* @param filterTopicIds - A set of topic ids used to filter the list of news resources. If
* this is empty AND filterAuthorIds is empty the list of news resources will not be filtered.
* @param filterAuthorIds - A set of author ids used to filter the list of news resources. If
* this is empty AND filterTopicIds is empty the list of news resources will not be filtered.
*
*/
operator fun invoke(
filterTopicIds: Set<String> = emptySet(),
filterAuthorIds: Set<String> = emptySet()
): Flow<List<SaveableNewsResource>> =
if (filterTopicIds.isEmpty() && filterAuthorIds.isEmpty()) {
newsRepository.getNewsResourcesStream()
} else {
newsRepository.getNewsResourcesStream(
filterTopicIds = filterTopicIds,
filterAuthorIds = filterAuthorIds
)
}.mapToSaveableNewsResources(bookmarkedNewsResourcesStream)
}
private fun Flow<List<NewsResource>>.mapToSaveableNewsResources(
savedNewsResourceIdsStream: Flow<Set<String>>
): Flow<List<SaveableNewsResource>> =
filterNot { it.isEmpty() }
.combine(savedNewsResourceIdsStream) { newsResources, savedNewsResourceIds ->
newsResources.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResourceIds.contains(newsResource.id)
)
}
}

@ -0,0 +1,49 @@
/*
* 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.domain.model.FollowableAuthor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* 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
) {
/**
* 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
)
}
.sortedBy { it.author.name }
}
}
}

@ -14,7 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.Author
/** /**
* An [author] with the additional information for whether or not it is followed. * An [author] with the additional information for whether or not it is followed.

@ -14,7 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.Topic
/** /**
* A [topic] with the additional information for whether or not it is followed. * A [topic] with the additional information for whether or not it is followed.

@ -14,7 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
/** /**
* A [NewsResource] with the additional information for whether it is saved. * A [NewsResource] with the additional information for whether it is saved.

@ -0,0 +1,119 @@
/*
* 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 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
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
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 GetFollowableTopicsStreamUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val topicsRepository = TestTopicsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetFollowableTopicsStreamUseCase(
topicsRepository,
userDataRepository
)
@Test
fun whenNoParams_followableTopicsAreReturnedWithNoSorting() = runTest {
// Obtain a stream of followable topics.
val followableTopics = useCase()
// Send some test topics and their followed state.
topicsRepository.sendTopics(testTopics)
userDataRepository.setFollowedTopicIds(setOf(testTopics[0].id, testTopics[2].id))
// Check that the order hasn't changed and that the correct topics are marked as followed.
assertEquals(
listOf(
FollowableTopic(testTopics[0], true),
FollowableTopic(testTopics[1], false),
FollowableTopic(testTopics[2], true),
),
followableTopics.first()
)
}
@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 {
// Obtain a stream of followable topics, sorted by name.
val followableTopics = useCase(
sortBy = NAME
)
// Send some test topics and their followed state.
topicsRepository.sendTopics(testTopics)
userDataRepository.setFollowedTopicIds(setOf())
// Check that the followable topics are sorted by the topic name.
assertEquals(
followableTopics.first(),
testTopics
.sortedBy { it.name }
.map {
FollowableTopic(it, false)
}
)
}
}
private val testTopics = listOf(
Topic("1", "Headlines", "", "", "", ""),
Topic("2", "Android Studio", "", "", "", ""),
Topic("3", "Compose", "", "", "", ""),
)

@ -0,0 +1,95 @@
/*
* 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)

@ -0,0 +1,205 @@
/*
* 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.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 com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
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 kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class GetSaveableNewsResourcesStreamUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val newsRepository = TestNewsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetSaveableNewsResourcesStreamUseCase(newsRepository, userDataRepository)
@Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the saveable news resources stream.
val saveableNewsResources = useCase()
// Send some news resources and bookmarks.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(
setOf(sampleNewsResources[0].id, sampleNewsResources[2].id)
)
// Check that the correct news resources are returned with their bookmarked state.
assertEquals(
listOf(
SaveableNewsResource(sampleNewsResources[0], true),
SaveableNewsResource(sampleNewsResources[1], false),
SaveableNewsResource(sampleNewsResources[2], true)
),
saveableNewsResources.first()
)
}
@Test
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of saveable news resources for the given topic id.
val saveableNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id))
// Send some news resources and bookmarks.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(setOf())
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.map { SaveableNewsResource(it, false) },
saveableNewsResources.first()
)
}
@Test
fun whenFilteredByAuthorId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of saveable news resources for the given author id.
val saveableNewsResources = useCase(filterAuthorIds = setOf(sampleAuthor1.id))
// Send some news resources and bookmarks.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(setOf())
// Check that only news resources with the given author id are returned.
assertEquals(
sampleNewsResources
.filter { it.authors.contains(sampleAuthor1) }
.map { SaveableNewsResource(it, false) },
saveableNewsResources.first()
)
}
@Test
fun whenFilteredByAuthorIdAndTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of saveable news resources for the given author id.
val saveableNewsResources = useCase(
filterAuthorIds = setOf(sampleAuthor2.id),
filterTopicIds = setOf(sampleTopic2.id),
)
// Send some news resources and bookmarks.
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setNewsResourceBookmarks(setOf())
// Check that only news resources with the given author id or topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.authors.contains(sampleAuthor2) || it.topics.contains(sampleTopic2) }
.map { SaveableNewsResource(it, false) },
saveableNewsResources.first()
)
}
}
private val sampleTopic1 = Topic(
id = "Topic1",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
private val sampleTopic2 = Topic(
id = "Topic2",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
private val sampleAuthor1 =
Author(
id = "Author1",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleAuthor2 =
Author(
id = "Author2",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleNewsResources = listOf(
NewsResource(
id = "1",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(sampleTopic1),
authors = listOf(sampleAuthor1)
),
NewsResource(
id = "2",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed with Paging. " +
"Transformations like inserting separators, when to create a new pager, and " +
"customisation options for consuming PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(sampleTopic1, sampleTopic2),
authors = listOf(sampleAuthor1)
),
NewsResource(
id = "3",
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(sampleTopic2),
authors = listOf(sampleAuthor2)
),
)

@ -0,0 +1,89 @@
/*
* 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.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 GetSortedFollowableAuthorsStreamUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val authorsRepository = TestAuthorsRepository()
val useCase = GetSortedFollowableAuthorsStreamUseCase(authorsRepository)
@Test
fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest {
// Obtain the stream of authors, specifying their followed state.
val followableAuthorsStream = useCase(followedAuthorIds = setOf(sampleAuthor1.id))
// Supply some authors.
authorsRepository.sendAuthors(sampleAuthors)
// Check that the authors have been sorted, and that the followed state is correct.
assertEquals(
followableAuthorsStream.first(),
listOf(
FollowableAuthor(sampleAuthor2, false),
FollowableAuthor(sampleAuthor1, true),
FollowableAuthor(sampleAuthor3, false)
)
)
}
}
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 = "Author2",
name = "Sandy",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleAuthors = listOf(sampleAuthor1, sampleAuthor2, sampleAuthor3)

@ -74,6 +74,16 @@ class TestUserDataRepository : UserDataRepository {
} }
} }
/**
* A test-only API to allow setting/unsetting of bookmarks.
*
*/
fun setNewsResourceBookmarks(newsResourceIds: Set<String>) {
currentUserData.let { current ->
_userData.tryEmit(current.copy(bookmarkedNewsResources = newsResourceIds))
}
}
/** /**
* A test-only API to allow querying the current followed topics. * A test-only API to allow querying the current followed topics.
*/ */

@ -22,6 +22,7 @@ plugins {
dependencies { dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:domain"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt) implementation(libs.coil.kt)

@ -33,7 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource 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.model.data.previewNewsResources
/** /**
@ -112,7 +112,10 @@ fun NewsFeedContentPreview() {
newsFeed( newsFeed(
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
previewNewsResources.map { previewNewsResources.map {
SaveableNewsResource(it, false) SaveableNewsResource(
it,
false
)
} }
), ),
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }

@ -20,11 +20,11 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule

@ -53,9 +53,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackg
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews

@ -20,12 +20,11 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
@ -44,7 +43,7 @@ class AuthorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository, authorsRepository: AuthorsRepository,
newsRepository: NewsRepository getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase
) : ViewModel() { ) : ViewModel() {
private val authorId: String = checkNotNull( private val authorId: String = checkNotNull(
@ -62,16 +61,13 @@ class AuthorViewModel @Inject constructor(
initialValue = AuthorUiState.Loading initialValue = AuthorUiState.Loading
) )
val newsUiState: StateFlow<NewsUiState> = newsUiStateStream( val newsUiState: StateFlow<NewsUiState> =
authorId = authorId, getSaveableNewsResourcesStream.newsUiStateStream(authorId = authorId)
userDataRepository = userDataRepository, .stateIn(
newsRepository = newsRepository scope = viewModelScope,
) started = SharingStarted.WhileSubscribed(5_000),
.stateIn( initialValue = NewsUiState.Loading
scope = viewModelScope, )
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsUiState.Loading
)
fun followAuthorToggle(followed: Boolean) { fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
@ -129,46 +125,18 @@ private fun authorUiStateStream(
} }
} }
private fun newsUiStateStream( private fun GetSaveableNewsResourcesStreamUseCase.newsUiStateStream(
authorId: String, authorId: String
newsRepository: NewsRepository,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> { ): Flow<NewsUiState> {
// Observe news // Observe news
val newsStream: Flow<List<NewsResource>> = newsRepository.getNewsResourcesStream( return this(
filterAuthorIds = setOf(element = authorId), filterAuthorIds = setOf(element = authorId)
filterTopicIds = emptySet() ).asResult()
) .map { newsResult ->
when (newsResult) {
// Observe bookmarks is Result.Success -> NewsUiState.Success(newsResult.data)
val bookmarkStream: Flow<Set<String>> = userDataRepository.userDataStream is Result.Loading -> NewsUiState.Loading
.map { it.bookmarkedNewsResources } is Result.Error -> NewsUiState.Error
return combine(
newsStream,
bookmarkStream,
::Pair
)
.asResult()
.map { newsToBookmarksResult ->
when (newsToBookmarksResult) {
is Result.Success -> {
val (news, bookmarks) = newsToBookmarksResult.data
NewsUiState.Success(
news.map { newsResource ->
SaveableNewsResource(
newsResource,
isSaved = bookmarks.contains(newsResource.id)
)
}
)
}
is Result.Loading -> {
NewsUiState.Loading
}
is Result.Error -> {
NewsUiState.Error
}
} }
} }
} }

@ -17,8 +17,9 @@
package com.google.samples.apps.nowinandroid.feature.author package com.google.samples.apps.nowinandroid.feature.author
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
@ -51,6 +52,10 @@ class AuthorViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository() private val authorsRepository = TestAuthorsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesStreamUseCase = GetSaveableNewsResourcesStreamUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: AuthorViewModel private lateinit var viewModel: AuthorViewModel
@Before @Before
@ -63,7 +68,7 @@ class AuthorViewModelTest {
), ),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
authorsRepository = authorsRepository, authorsRepository = authorsRepository,
newsRepository = newsRepository getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase
) )
} }

@ -31,7 +31,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource 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.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals

@ -56,7 +56,7 @@ fun BookmarksRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel() viewModel: BookmarksViewModel = hiltViewModel()
) { ) {
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedUiState.collectAsStateWithLifecycle()
BookmarksScreen( BookmarksScreen(
feedState = feedState, feedState = feedState,
removeFromBookmarks = viewModel::removeFromSavedResources, removeFromBookmarks = viewModel::removeFromSavedResources,

@ -18,18 +18,15 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource 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.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
@ -38,41 +35,20 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class BookmarksViewModel @Inject constructor( class BookmarksViewModel @Inject constructor(
newsRepository: NewsRepository, private val userDataRepository: UserDataRepository,
private val userDataRepository: UserDataRepository getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase
) : ViewModel() { ) : ViewModel() {
private val savedNewsResourcesState: StateFlow<Set<String>> =
userDataRepository.userDataStream
.map { userData ->
userData.bookmarkedNewsResources
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet()
)
val feedState: StateFlow<NewsFeedUiState> = val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResourcesStream()
newsRepository .filterNot { it.isEmpty() }
.getNewsResourcesStream() .map { newsResources -> newsResources.filter(SaveableNewsResource::isSaved) } // Only show bookmarked news resources.
.mapToFeedState(savedNewsResourcesState) .map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.stateIn( .onStart { emit(Loading) }
scope = viewModelScope, .stateIn(
started = SharingStarted.WhileSubscribed(5_000), scope = viewModelScope,
initialValue = Loading started = SharingStarted.WhileSubscribed(5_000),
) initialValue = Loading
)
private fun Flow<List<NewsResource>>.mapToFeedState(
savedNewsResourcesState: Flow<Set<String>>
): Flow<NewsFeedUiState> =
filterNot { it.isEmpty() }
.combine(savedNewsResourcesState) { newsResources, savedNewsResources ->
newsResources
.filter { newsResource -> savedNewsResources.contains(newsResource.id) }
.map { SaveableNewsResource(it, true) }
}
.map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(Loading) }
fun removeFromSavedResources(newsResourceId: String) { fun removeFromSavedResources(newsResourceId: String) {
viewModelScope.launch { viewModelScope.launch {

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.bookmarks package com.google.samples.apps.nowinandroid.feature.bookmarks
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -42,28 +43,32 @@ class BookmarksViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesStreamUseCase = GetSaveableNewsResourcesStreamUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: BookmarksViewModel private lateinit var viewModel: BookmarksViewModel
@Before @Before
fun setup() { fun setup() {
viewModel = BookmarksViewModel( viewModel = BookmarksViewModel(
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
newsRepository = newsRepository getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase
) )
} }
@Test @Test
fun stateIsInitiallyLoading() = runTest { fun stateIsInitiallyLoading() = runTest {
assertEquals(Loading, viewModel.feedState.value) assertEquals(Loading, viewModel.feedUiState.value)
} }
@Test @Test
fun oneBookmark_showsInFeed() = runTest { fun oneBookmark_showsInFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }
newsRepository.sendNewsResources(previewNewsResources) newsRepository.sendNewsResources(previewNewsResources)
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true) userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
val item = viewModel.feedState.value val item = viewModel.feedUiState.value
assertTrue(item is Success) assertTrue(item is Success)
assertEquals((item as Success).feed.size, 1) assertEquals((item as Success).feed.size, 1)
@ -72,7 +77,7 @@ class BookmarksViewModelTest {
@Test @Test
fun oneBookmark_whenRemoving_removesFromFeed() = runTest { fun oneBookmark_whenRemoving_removesFromFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }
// Set the news resources to be used by this test // Set the news resources to be used by this test
newsRepository.sendNewsResources(previewNewsResources) newsRepository.sendNewsResources(previewNewsResources)
// Start with the resource saved // Start with the resource saved
@ -80,7 +85,7 @@ class BookmarksViewModelTest {
// Use viewModel to remove saved resource // Use viewModel to remove saved resource
viewModel.removeFromSavedResources(previewNewsResources[0].id) viewModel.removeFromSavedResources(previewNewsResources[0].id)
// Verify list of saved resources is now empty // Verify list of saved resources is now empty
val item = viewModel.feedState.value val item = viewModel.feedUiState.value
assertTrue(item is Success) assertTrue(item is Success)
assertEquals((item as Success).feed.size, 0) assertEquals((item as Success).feed.size, 0)

@ -28,10 +28,10 @@ import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
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
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState

@ -57,8 +57,8 @@ import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton 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.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
@Composable @Composable

@ -16,8 +16,8 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic 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 interests selection state for the for you screen.

@ -87,9 +87,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggl
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
@ -104,7 +104,7 @@ fun ForYouRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel() viewModel: ForYouViewModel = hiltViewModel()
) { ) {
val interestsSelectionState by viewModel.interestsSelectionState.collectAsStateWithLifecycle() val interestsSelectionState by viewModel.interestsSelectionUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isOffline by viewModel.isOffline.collectAsStateWithLifecycle() val isOffline by viewModel.isOffline.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()

@ -24,16 +24,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable import androidx.lifecycle.viewmodel.compose.saveable
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsStreamUseCase
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource 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.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.FollowedInterestsUiState.FollowedInterests 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.None
@ -44,7 +41,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -57,10 +53,10 @@ import kotlinx.coroutines.launch
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
syncStatusMonitor: SyncStatusMonitor, syncStatusMonitor: SyncStatusMonitor,
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository,
private val newsRepository: NewsRepository,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
private val getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase,
getSortedFollowableAuthorsStream: GetSortedFollowableAuthorsStreamUseCase,
getFollowableTopicsStream: GetFollowableTopicsStreamUseCase,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@ -82,17 +78,6 @@ class ForYouViewModel @Inject constructor(
initialValue = Unknown initialValue = Unknown
) )
private val savedNewsResourcesState: StateFlow<Set<String>> =
userDataRepository.userDataStream
.map { userData ->
userData.bookmarkedNewsResources
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet()
)
/** /**
* The in-progress set of topics to be selected, persisted through process death with a * The in-progress set of topics to be selected, persisted through process death with a
* [SavedStateHandle]. * [SavedStateHandle].
@ -129,17 +114,16 @@ class ForYouViewModel @Inject constructor(
followedInterestsUiState, followedInterestsUiState,
snapshotFlow { inProgressTopicSelection }, snapshotFlow { inProgressTopicSelection },
snapshotFlow { inProgressAuthorSelection } snapshotFlow { inProgressAuthorSelection }
) { followedInterestsUserState, inProgressTopicSelection, inProgressAuthorSelection -> ) { followedInterestsUiState, inProgressTopicSelection, inProgressAuthorSelection ->
when (followedInterestsUserState) { when (followedInterestsUiState) {
// If we don't know the current selection state, emit loading. // If we don't know the current selection state, emit loading.
Unknown -> flowOf<NewsFeedUiState>(NewsFeedUiState.Loading) Unknown -> flowOf<NewsFeedUiState>(NewsFeedUiState.Loading)
// If the user has followed topics, use those followed topics to populate the feed // If the user has followed topics, use those followed topics to populate the feed
is FollowedInterests -> { is FollowedInterests -> {
getSaveableNewsResourcesStream(
newsRepository.getNewsResourcesStream( filterTopicIds = followedInterestsUiState.topicIds,
filterTopicIds = followedInterestsUserState.topicIds, filterAuthorIds = followedInterestsUiState.authorIds
filterAuthorIds = followedInterestsUserState.authorIds ).mapToFeedState()
).mapToFeedState(savedNewsResourcesState)
} }
// If the user hasn't followed interests yet, show a realtime populated feed based // If the user hasn't followed interests yet, show a realtime populated feed based
// on the in-progress interests selections, if there are any. // on the in-progress interests selections, if there are any.
@ -147,10 +131,10 @@ class ForYouViewModel @Inject constructor(
if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) { if (inProgressTopicSelection.isEmpty() && inProgressAuthorSelection.isEmpty()) {
flowOf<NewsFeedUiState>(NewsFeedUiState.Success(emptyList())) flowOf<NewsFeedUiState>(NewsFeedUiState.Success(emptyList()))
} else { } else {
newsRepository.getNewsResourcesStream( getSaveableNewsResourcesStream(
filterTopicIds = inProgressTopicSelection, filterTopicIds = inProgressTopicSelection,
filterAuthorIds = inProgressAuthorSelection filterAuthorIds = inProgressAuthorSelection
).mapToFeedState(savedNewsResourcesState) ).mapToFeedState()
} }
} }
} }
@ -165,33 +149,20 @@ class ForYouViewModel @Inject constructor(
initialValue = NewsFeedUiState.Loading initialValue = NewsFeedUiState.Loading
) )
val interestsSelectionState: StateFlow<ForYouInterestsSelectionUiState> = val interestsSelectionUiState: StateFlow<ForYouInterestsSelectionUiState> =
combine( combine(
followedInterestsUiState, followedInterestsUiState,
topicsRepository.getTopicsStream(), getFollowableTopicsStream(
authorsRepository.getAuthorsStream(), followedTopicIdsStream = snapshotFlow { inProgressTopicSelection }
snapshotFlow { inProgressTopicSelection }, ),
snapshotFlow { inProgressAuthorSelection }, snapshotFlow { inProgressAuthorSelection }.flatMapLatest {
) { followedInterestsUserState, availableTopics, availableAuthors, inProgressTopicSelection, getSortedFollowableAuthorsStream(it)
inProgressAuthorSelection -> }
) { followedInterestsUiState, topics, authors ->
when (followedInterestsUserState) { when (followedInterestsUiState) {
Unknown -> ForYouInterestsSelectionUiState.Loading Unknown -> ForYouInterestsSelectionUiState.Loading
is FollowedInterests -> ForYouInterestsSelectionUiState.NoInterestsSelection is FollowedInterests -> ForYouInterestsSelectionUiState.NoInterestsSelection
None -> { None -> {
val topics = availableTopics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in inProgressTopicSelection
)
}
val authors = availableAuthors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in inProgressAuthorSelection
)
}
if (topics.isEmpty() && authors.isEmpty()) { if (topics.isEmpty() && authors.isEmpty()) {
ForYouInterestsSelectionUiState.LoadFailed ForYouInterestsSelectionUiState.LoadFailed
} else { } else {
@ -257,17 +228,6 @@ class ForYouViewModel @Inject constructor(
} }
} }
private fun Flow<List<NewsResource>>.mapToFeedState( private fun Flow<List<SaveableNewsResource>>.mapToFeedState(): Flow<NewsFeedUiState> =
savedNewsResourcesState: Flow<Set<String>> map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
): Flow<NewsFeedUiState> =
filterNot { it.isEmpty() }
.combine(savedNewsResourcesState) { newsResources, savedNewsResources ->
newsResources.map { newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = savedNewsResources.contains(newsResource.id)
)
}
}
.map<List<SaveableNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
.onStart { emit(NewsFeedUiState.Loading) } .onStart { emit(NewsFeedUiState.Loading) }

@ -17,12 +17,15 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.SavedStateHandle 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
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
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
@ -57,6 +60,17 @@ class ForYouViewModelTest {
private val authorsRepository = TestAuthorsRepository() private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesStreamUseCase = GetSaveableNewsResourcesStreamUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private val getSortedFollowableAuthorsStream = GetSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository
)
private val getFollowableTopicsStreamUseCase = GetFollowableTopicsStreamUseCase(
topicsRepository = topicsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: ForYouViewModel private lateinit var viewModel: ForYouViewModel
@Before @Before
@ -65,9 +79,9 @@ class ForYouViewModelTest {
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
syncStatusMonitor = syncStatusMonitor, syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
authorsRepository = authorsRepository, getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase,
topicsRepository = topicsRepository, getSortedFollowableAuthorsStream = getSortedFollowableAuthorsStream,
newsRepository = newsRepository, getFollowableTopicsStream = getFollowableTopicsStreamUseCase,
savedStateHandle = SavedStateHandle() savedStateHandle = SavedStateHandle()
) )
} }
@ -76,7 +90,7 @@ class ForYouViewModelTest {
fun stateIsInitiallyLoading() = runTest { fun stateIsInitiallyLoading() = runTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
} }
@ -84,14 +98,14 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest { fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
@ -117,14 +131,14 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest { fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
@ -135,14 +149,14 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenTopicsAreLoading() = runTest { fun stateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value) assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
@ -153,14 +167,14 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenAuthorsAreLoading() = runTest { fun stateIsLoadingWhenAuthorsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedAuthorIds(emptySet()) userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.Loading, ForYouInterestsSelectionUiState.Loading,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value) assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
@ -171,7 +185,7 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsInterestsSelectionWhenNewsResourcesAreLoading() = runTest { fun stateIsInterestsSelectionWhenNewsResourcesAreLoading() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -252,7 +266,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -268,7 +282,7 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsInterestsSelectionAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest { fun stateIsInterestsSelectionAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -350,7 +364,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -367,7 +381,7 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsWithoutInterestsSelectionAfterLoadingFollowedTopics() = runTest { fun stateIsWithoutInterestsSelectionAfterLoadingFollowedTopics() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)
@ -377,7 +391,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
@ -385,7 +399,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -407,7 +421,7 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsWithoutInterestsSelectionAfterLoadingFollowedAuthors() = runTest { fun stateIsWithoutInterestsSelectionAfterLoadingFollowedAuthors() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)
@ -417,7 +431,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Loading, NewsFeedUiState.Loading,
@ -428,7 +442,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -449,7 +463,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest { fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -531,7 +545,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -615,7 +629,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -640,7 +654,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterSelectingAuthor() = runTest { fun topicSelectionUpdatesAfterSelectingAuthor() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -722,7 +736,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -806,7 +820,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -831,7 +845,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest { fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -916,7 +930,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -932,7 +946,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterUnselectingAuthor() = runTest { fun topicSelectionUpdatesAfterUnselectingAuthor() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -1017,7 +1031,7 @@ class ForYouViewModelTest {
) )
), ),
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -1033,7 +1047,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterSavingTopicsOnly() = runTest { fun topicSelectionUpdatesAfterSavingTopicsOnly() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -1047,7 +1061,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -1074,7 +1088,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterSavingAuthorsOnly() = runTest { fun topicSelectionUpdatesAfterSavingAuthorsOnly() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -1088,7 +1102,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -1111,7 +1125,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionUpdatesAfterSavingAuthorsAndTopics() = runTest { fun topicSelectionUpdatesAfterSavingAuthorsAndTopics() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -1126,7 +1140,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -1153,7 +1167,7 @@ class ForYouViewModelTest {
@Test @Test
fun topicSelectionIsResetAfterSavingTopicsAndRemovingThem() = runTest { fun topicSelectionIsResetAfterSavingTopicsAndRemovingThem() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -1239,7 +1253,7 @@ class ForYouViewModelTest {
) )
) )
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -1255,7 +1269,7 @@ class ForYouViewModelTest {
@Test @Test
fun authorSelectionIsResetAfterSavingAuthorsAndRemovingThem() = runTest { fun authorSelectionIsResetAfterSavingAuthorsAndRemovingThem() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -1341,7 +1355,7 @@ class ForYouViewModelTest {
) )
) )
), ),
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(
@ -1357,7 +1371,7 @@ class ForYouViewModelTest {
@Test @Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest { fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.interestsSelectionUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -1369,7 +1383,7 @@ class ForYouViewModelTest {
assertEquals( assertEquals(
ForYouInterestsSelectionUiState.NoInterestsSelection, ForYouInterestsSelectionUiState.NoInterestsSelection,
viewModel.interestsSelectionState.value viewModel.interestsSelectionUiState.value
) )
assertEquals( assertEquals(
NewsFeedUiState.Success( NewsFeedUiState.Success(

@ -25,9 +25,9 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen 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.InterestsTabState

@ -36,8 +36,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRo
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews

@ -18,11 +18,12 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsStreamUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.GetPersistentSortedFollowableAuthorsStreamUseCase
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
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -36,9 +37,9 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class InterestsViewModel @Inject constructor( class InterestsViewModel @Inject constructor(
private val userDataRepository: UserDataRepository, val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository, getFollowableTopicsStream: GetFollowableTopicsStreamUseCase,
topicsRepository: TopicsRepository getPersistentSortedFollowableAuthorsStream: GetPersistentSortedFollowableAuthorsStreamUseCase
) : ViewModel() { ) : ViewModel() {
private val _tabState = MutableStateFlow( private val _tabState = MutableStateFlow(
@ -50,35 +51,14 @@ class InterestsViewModel @Inject constructor(
val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow() val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> = combine( val uiState: StateFlow<InterestsUiState> = combine(
userDataRepository.userDataStream, getPersistentSortedFollowableAuthorsStream(),
authorsRepository.getAuthorsStream(), getFollowableTopicsStream(sortBy = TopicSortField.NAME),
topicsRepository.getTopicsStream(), InterestsUiState::Interests
) { userData, availableAuthors, availableTopics -> ).stateIn(
scope = viewModelScope,
InterestsUiState.Interests( started = SharingStarted.WhileSubscribed(5_000),
authors = availableAuthors initialValue = InterestsUiState.Loading
.map { author -> )
FollowableAuthor(
author = author,
isFollowed = author.id in userData.followedAuthors
)
}
.sortedBy { it.author.name },
topics = availableTopics
.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in userData.followedTopics
)
}
.sortedBy { it.topic.name }
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading
)
fun followTopic(followedTopicId: String, followed: Boolean) { fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch { viewModelScope.launch {

@ -29,8 +29,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
@Composable @Composable
fun TopicsTabContent( fun TopicsTabContent(

@ -16,9 +16,11 @@
package com.google.samples.apps.nowinandroid.interests 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.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.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
@ -47,14 +49,23 @@ class InterestsViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository() private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val getFollowableTopicsStreamUseCase = GetFollowableTopicsStreamUseCase(
topicsRepository = topicsRepository,
userDataRepository = userDataRepository
)
private val getPersistentSortedFollowableAuthorsStream =
GetPersistentSortedFollowableAuthorsStreamUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: InterestsViewModel private lateinit var viewModel: InterestsViewModel
@Before @Before
fun setup() { fun setup() {
viewModel = InterestsViewModel( viewModel = InterestsViewModel(
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
authorsRepository = authorsRepository, getFollowableTopicsStream = getFollowableTopicsStreamUseCase,
topicsRepository = topicsRepository, getPersistentSortedFollowableAuthorsStream = getPersistentSortedFollowableAuthorsStream
) )
} }

@ -24,10 +24,10 @@ import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before

@ -51,8 +51,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackg
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource 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.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews

@ -19,12 +19,11 @@ package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.core.result.asResult
@ -44,7 +43,8 @@ class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository, topicsRepository: TopicsRepository,
newsRepository: NewsRepository // newsRepository: NewsRepository,
getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase
) : ViewModel() { ) : ViewModel() {
private val topicId: String = checkNotNull(savedStateHandle[TopicDestination.topicIdArg]) private val topicId: String = checkNotNull(savedStateHandle[TopicDestination.topicIdArg])
@ -63,7 +63,7 @@ class TopicViewModel @Inject constructor(
val newUiState: StateFlow<NewsUiState> = newsUiStateStream( val newUiState: StateFlow<NewsUiState> = newsUiStateStream(
topicId = topicId, topicId = topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
newsRepository = newsRepository getSaveableNewsResourcesStream = getSaveableNewsResourcesStream
) )
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -129,11 +129,11 @@ private fun topicUiStateStream(
private fun newsUiStateStream( private fun newsUiStateStream(
topicId: String, topicId: String,
newsRepository: NewsRepository, getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase,
userDataRepository: UserDataRepository, userDataRepository: UserDataRepository,
): Flow<NewsUiState> { ): Flow<NewsUiState> {
// Observe news // Observe news
val newsStream: Flow<List<NewsResource>> = newsRepository.getNewsResourcesStream( val newsStream: Flow<List<SaveableNewsResource>> = getSaveableNewsResourcesStream(
filterAuthorIds = emptySet(), filterAuthorIds = emptySet(),
filterTopicIds = setOf(element = topicId), filterTopicIds = setOf(element = topicId),
) )
@ -152,14 +152,7 @@ private fun newsUiStateStream(
when (newsToBookmarksResult) { when (newsToBookmarksResult) {
is Result.Success -> { is Result.Success -> {
val (news, bookmarks) = newsToBookmarksResult.data val (news, bookmarks) = newsToBookmarksResult.data
NewsUiState.Success( NewsUiState.Success(news)
news.map { newsResource ->
SaveableNewsResource(
newsResource,
isSaved = bookmarks.contains(newsResource.id)
)
}
)
} }
is Result.Loading -> { is Result.Loading -> {
NewsUiState.Loading NewsUiState.Loading

@ -17,7 +17,8 @@
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesStreamUseCase
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.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -51,6 +52,10 @@ class TopicViewModelTest {
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesStreamUseCase = GetSaveableNewsResourcesStreamUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: TopicViewModel private lateinit var viewModel: TopicViewModel
@Before @Before
@ -60,7 +65,7 @@ class TopicViewModelTest {
SavedStateHandle(mapOf(TopicDestination.topicIdArg to testInputTopics[0].topic.id)), SavedStateHandle(mapOf(TopicDestination.topicIdArg to testInputTopics[0].topic.id)),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
newsRepository = newsRepository getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase
) )
} }

@ -41,6 +41,7 @@ include(":core:database")
include(":core:datastore") include(":core:datastore")
include(":core:datastore-test") include(":core:datastore-test")
include(":core:designsystem") include(":core:designsystem")
include(":core:domain")
include(":core:model") include(":core:model")
include(":core:navigation") include(":core:navigation")
include(":core:network") include(":core:network")

Loading…
Cancel
Save