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:common"))
add("implementation", project(":core:navigation"))
add("implementation", project(":core:domain"))
add("testImplementation", 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.
*/
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.

@ -14,7 +14,9 @@
* 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.

@ -14,7 +14,9 @@
* 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.

@ -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.
*/

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

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

@ -20,11 +20,11 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
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.FollowableAuthor
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.SaveableNewsResource
import kotlinx.datetime.Instant
import org.junit.Before
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.NiaLoadingWheel
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.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.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews

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

@ -17,8 +17,9 @@
package com.google.samples.apps.nowinandroid.feature.author
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.FollowableAuthor
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.testing.repository.TestAuthorsRepository
@ -51,6 +52,10 @@ class AuthorViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesStreamUseCase = GetSaveableNewsResourcesStreamUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: AuthorViewModel
@Before
@ -63,7 +68,7 @@ class AuthorViewModelTest {
),
userDataRepository = userDataRepository,
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.performClick
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.ui.NewsFeedUiState
import org.junit.Assert.assertEquals

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

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

@ -16,6 +16,7 @@
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.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -42,28 +43,32 @@ class BookmarksViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesStreamUseCase = GetSaveableNewsResourcesStreamUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: BookmarksViewModel
@Before
fun setup() {
viewModel = BookmarksViewModel(
userDataRepository = userDataRepository,
newsRepository = newsRepository
getSaveableNewsResourcesStream = getSaveableNewsResourcesStreamUseCase
)
}
@Test
fun stateIsInitiallyLoading() = runTest {
assertEquals(Loading, viewModel.feedState.value)
assertEquals(Loading, viewModel.feedUiState.value)
}
@Test
fun oneBookmark_showsInFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }
newsRepository.sendNewsResources(previewNewsResources)
userDataRepository.updateNewsResourceBookmark(previewNewsResources[0].id, true)
val item = viewModel.feedState.value
val item = viewModel.feedUiState.value
assertTrue(item is Success)
assertEquals((item as Success).feed.size, 1)
@ -72,7 +77,7 @@ class BookmarksViewModelTest {
@Test
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
newsRepository.sendNewsResources(previewNewsResources)
// Start with the resource saved
@ -80,7 +85,7 @@ class BookmarksViewModelTest {
// Use viewModel to remove saved resource
viewModel.removeFromSavedResources(previewNewsResources[0].id)
// Verify list of saved resources is now empty
val item = viewModel.feedState.value
val item = viewModel.feedUiState.value
assertTrue(item is Success)
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.onNodeWithText
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.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.previewNewsResources
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.icon.NiaIcons
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.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
@Composable

@ -16,8 +16,8 @@
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.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
/**
* A sealed hierarchy describing the interests selection state for the for you screen.

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

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

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

@ -25,9 +25,9 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import 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.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.feature.interests.InterestsScreen
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.icon.NiaIcons
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.model.data.FollowableTopic
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.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
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.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.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
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.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 javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
@ -36,9 +37,9 @@ import kotlinx.coroutines.launch
@HiltViewModel
class InterestsViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository
val userDataRepository: UserDataRepository,
getFollowableTopicsStream: GetFollowableTopicsStreamUseCase,
getPersistentSortedFollowableAuthorsStream: GetPersistentSortedFollowableAuthorsStreamUseCase
) : ViewModel() {
private val _tabState = MutableStateFlow(
@ -50,35 +51,14 @@ class InterestsViewModel @Inject constructor(
val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> = combine(
userDataRepository.userDataStream,
authorsRepository.getAuthorsStream(),
topicsRepository.getTopicsStream(),
) { userData, availableAuthors, availableTopics ->
InterestsUiState.Interests(
authors = availableAuthors
.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
)
getPersistentSortedFollowableAuthorsStream(),
getFollowableTopicsStream(sortBy = TopicSortField.NAME),
InterestsUiState::Interests
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading
)
fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch {

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

@ -16,9 +16,11 @@
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.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.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
@ -47,14 +49,23 @@ class InterestsViewModelTest {
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
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
@Before
fun setup() {
viewModel = InterestsViewModel(
userDataRepository = userDataRepository,
authorsRepository = authorsRepository,
topicsRepository = topicsRepository,
getFollowableTopicsStream = getFollowableTopicsStreamUseCase,
getPersistentSortedFollowableAuthorsStream = getPersistentSortedFollowableAuthorsStream
)
}

@ -24,10 +24,10 @@ import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import 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.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.datetime.Instant
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.NiaLoadingWheel
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.model.data.SaveableNewsResource
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.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
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.ViewModel
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.UserDataRepository
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.SaveableNewsResource
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.domain.model.SaveableNewsResource
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.asResult
@ -44,7 +43,8 @@ class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
newsRepository: NewsRepository
// newsRepository: NewsRepository,
getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase
) : ViewModel() {
private val topicId: String = checkNotNull(savedStateHandle[TopicDestination.topicIdArg])
@ -63,7 +63,7 @@ class TopicViewModel @Inject constructor(
val newUiState: StateFlow<NewsUiState> = newsUiStateStream(
topicId = topicId,
userDataRepository = userDataRepository,
newsRepository = newsRepository
getSaveableNewsResourcesStream = getSaveableNewsResourcesStream
)
.stateIn(
scope = viewModelScope,
@ -129,11 +129,11 @@ private fun topicUiStateStream(
private fun newsUiStateStream(
topicId: String,
newsRepository: NewsRepository,
getSaveableNewsResourcesStream: GetSaveableNewsResourcesStreamUseCase,
userDataRepository: UserDataRepository,
): Flow<NewsUiState> {
// Observe news
val newsStream: Flow<List<NewsResource>> = newsRepository.getNewsResourcesStream(
val newsStream: Flow<List<SaveableNewsResource>> = getSaveableNewsResourcesStream(
filterAuthorIds = emptySet(),
filterTopicIds = setOf(element = topicId),
)
@ -152,14 +152,7 @@ private fun newsUiStateStream(
when (newsToBookmarksResult) {
is Result.Success -> {
val (news, bookmarks) = newsToBookmarksResult.data
NewsUiState.Success(
news.map { newsResource ->
SaveableNewsResource(
newsResource,
isSaved = bookmarks.contains(newsResource.id)
)
}
)
NewsUiState.Success(news)
}
is Result.Loading -> {
NewsUiState.Loading

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

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

Loading…
Cancel
Save