Remove author concept from app

Change-Id: Icd03c0288ba5f3f23dbcbdbefbbe802db2815793
pull/473/head
Jolanda Verhoef 2 years ago
parent 56b3c1d0b9
commit 4ba63c0de8

@ -28,9 +28,6 @@
<option name="JD_PRESERVE_LINE_FEEDS" value="true" />
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="99" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="99" />
<option name="IMPORT_NESTED_CLASSES" value="true" />

@ -22,7 +22,7 @@ The app is currently in development. The `demoRelease` variant is [available on
**Now in Android** displays content from the
[Now in Android](https://developer.android.com/series/now-in-android) series. Users can browse for
links to recent videos, articles and other content. Users can also follow topics they are interested
in or follow specific authors.
in.
## Screenshots

@ -78,7 +78,6 @@ android {
}
dependencies {
implementation(project(":feature:author"))
implementation(project(":feature:interests"))
implementation(project(":feature:foryou"))
implementation(project(":feature:bookmarks"))

@ -20,8 +20,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.author.navigation.authorScreen
import com.google.samples.apps.nowinandroid.feature.author.navigation.navigateToAuthor
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
@ -54,12 +52,8 @@ fun NiaNavHost(
navigateToTopic = { topicId ->
navController.navigateToTopic(topicId)
},
navigateToAuthor = { authorId ->
navController.navigateToAuthor(authorId)
},
nestedGraphs = {
topicScreen(onBackClick)
authorScreen(onBackClick)
}
)
}

@ -21,7 +21,6 @@ import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectAuthors
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
@ -47,7 +46,6 @@ class BaselineProfileGenerator {
// Scroll the feed critical user journey
forYouWaitForContent()
forYouSelectAuthors(true)
forYouScrollFeedDownUp()
// Navigate to saved screen

@ -31,43 +31,6 @@ fun MacrobenchmarkScope.forYouWaitForContent() {
obj.wait(untilHasChildren(), 30_000)
}
/**
* Selects some authors, which will show the feed content for them.
* [recheckAuthorsIfChecked] Authors may be already checked from the previous iteration.
*/
fun MacrobenchmarkScope.forYouSelectAuthors(recheckAuthorsIfChecked: Boolean = false) {
val authors = device.findObject(By.res("forYou:authors"))
// Set gesture margin from sides not to trigger system gesture navigation
val horizontalMargin = 10 * authors.visibleBounds.width() / 100
authors.setGestureMargins(horizontalMargin, 0, horizontalMargin, 0)
// Select some authors to show some feed content
repeat(3) { index ->
val author = authors.children[index % authors.childCount]
when {
// Author wasn't checked, so just do that
!author.isChecked -> {
author.click()
device.waitForIdle()
}
// The author was checked already and we want to recheck it, so just do it twice
recheckAuthorsIfChecked -> {
repeat(2) {
author.click()
device.waitForIdle()
}
}
else -> {
// The author is checked, but we don't recheck it
}
}
}
}
fun MacrobenchmarkScope.forYouScrollFeedDownUp() {
val feedList = device.findObject(By.res("forYou:feed"))
device.flingElementDownUp(feedList)

@ -50,7 +50,6 @@ class ScrollForYouFeedBenchmark {
}
) {
forYouWaitForContent()
forYouSelectAuthors()
forYouScrollFeedDownUp()
}
}

@ -17,11 +17,9 @@
package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.di.DataModule
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.fake.FakeAuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
@ -42,11 +40,6 @@ interface TestDataModule {
fakeTopicsRepository: FakeTopicsRepository
): TopicsRepository
@Binds
fun bindsAuthorRepository(
fakeAuthorsRepository: FakeAuthorsRepository
): AuthorsRepository
@Binds
fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository

@ -16,9 +16,7 @@
package com.google.samples.apps.nowinandroid.core.data.di
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.OfflineFirstAuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
@ -40,11 +38,6 @@ interface DataModule {
topicsRepository: OfflineFirstTopicsRepository
): TopicsRepository
@Binds
fun bindsAuthorsRepository(
authorsRepository: OfflineFirstAuthorsRepository
): AuthorsRepository
@Binds
fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository

@ -1,29 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
fun NetworkAuthor.asEntity() = AuthorEntity(
id = id,
name = name,
imageUrl = imageUrl,
twitter = twitter,
mediumPage = mediumPage,
bio = bio,
)

@ -16,8 +16,6 @@
package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -44,22 +42,6 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
type = type,
)
/**
* A shell [AuthorEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
*/
fun NetworkNewsResource.authorEntityShells() =
authors.map { authorId ->
AuthorEntity(
id = authorId,
name = "",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
}
/**
* A shell [TopicEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
@ -83,11 +65,3 @@ fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef>
topicId = topicId
)
}
fun NetworkNewsResource.authorCrossReferences(): List<NewsResourceAuthorCrossRef> =
authors.map { authorId ->
NewsResourceAuthorCrossRef(
newsResourceId = id,
authorId = authorId
)
}

@ -1,33 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Syncable
import com.google.samples.apps.nowinandroid.core.model.data.Author
import kotlinx.coroutines.flow.Flow
interface AuthorsRepository : Syncable {
/**
* Gets the available Authors as a stream
*/
fun getAuthors(): Flow<List<Author>>
/**
* Gets data for a specific author
*/
fun getAuthor(id: String): Flow<Author>
}

@ -30,10 +30,9 @@ interface NewsRepository : Syncable {
fun getNewsResources(): Flow<List<NewsResource>>
/**
* Returns available news resources as a stream filtered by authors or topics.
* Returns available news resources as a stream filtered by topics.
*/
fun getNewsResources(
filterAuthorIds: Set<String> = emptySet(),
filterTopicIds: Set<String> = emptySet(),
): Flow<List<NewsResource>>
}

@ -1,68 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.changeListSync
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Disk storage backed implementation of the [AuthorsRepository].
* Reads are exclusively from local storage to support offline access.
*/
class OfflineFirstAuthorsRepository @Inject constructor(
private val authorDao: AuthorDao,
private val network: NiaNetworkDataSource,
) : AuthorsRepository {
override fun getAuthor(id: String): Flow<Author> =
authorDao.getAuthorEntity(id).map {
it.asExternalModel()
}
override fun getAuthors(): Flow<List<Author>> =
authorDao.getAuthorEntities()
.map { it.map(AuthorEntity::asExternalModel) }
override suspend fun syncWith(synchronizer: Synchronizer): Boolean =
synchronizer.changeListSync(
versionReader = ChangeListVersions::authorVersion,
changeListFetcher = { currentVersion ->
network.getAuthorChangeList(after = currentVersion)
},
versionUpdater = { latestVersion ->
copy(authorVersion = latestVersion)
},
modelDeleter = authorDao::deleteAuthors,
modelUpdater = { changedIds ->
val networkAuthors = network.getAuthors(ids = changedIds)
authorDao.upsertAuthors(
entities = networkAuthors.map(NetworkAuthor::asEntity)
)
}
)
}

@ -19,14 +19,10 @@ package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.changeListSync
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -44,7 +40,6 @@ import kotlinx.coroutines.flow.map
*/
class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val authorDao: AuthorDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : NewsRepository {
@ -54,10 +49,8 @@ class OfflineFirstNewsRepository @Inject constructor(
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
filterAuthorIds = filterAuthorIds,
filterTopicIds = filterTopicIds
)
.map { it.map(PopulatedNewsResource::asExternalModel) }
@ -83,12 +76,6 @@ class OfflineFirstNewsRepository @Inject constructor(
.flatten()
.distinctBy(TopicEntity::id)
)
authorDao.insertOrIgnoreAuthors(
authorEntities = networkNewsResources
.map(NetworkNewsResource::authorEntityShells)
.flatten()
.distinctBy(AuthorEntity::id)
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity)
@ -99,12 +86,6 @@ class OfflineFirstNewsRepository @Inject constructor(
.distinct()
.flatten()
)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences = networkNewsResources
.map(NetworkNewsResource::authorCrossReferences)
.distinct()
.flatten()
)
}
)
}

@ -36,12 +36,6 @@ class OfflineFirstUserDataRepository @Inject constructor(
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) =
niaPreferencesDataSource.setFollowedAuthorIds(followedAuthorIds)
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) =
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)

@ -38,16 +38,6 @@ interface UserDataRepository {
*/
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean)
/**
* Sets the user's currently followed authors
*/
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>)
/**
* Toggles the user's newly followed/unfollowed author
*/
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean)
/**
* Updates the bookmarked status for a news resource
*/

@ -1,63 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
/**
* Fake implementation of the [AuthorsRepository] that returns hardcoded authors.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeAuthorsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource
) : AuthorsRepository {
override fun getAuthors(): Flow<List<Author>> = flow {
emit(
datasource.getAuthors().map {
Author(
id = it.id,
name = it.name,
imageUrl = it.imageUrl,
twitter = it.twitter,
mediumPage = it.mediumPage,
bio = it.bio,
)
}
)
}.flowOn(ioDispatcher)
override fun getAuthor(id: String): Flow<Author> {
return getAuthors().map { it.first { author -> author.id == id } }
}
override suspend fun syncWith(synchronizer: Synchronizer) = true
}

@ -53,19 +53,16 @@ class FakeNewsRepository @Inject constructor(
}.flowOn(ioDispatcher)
override fun getNewsResources(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>,
): Flow<List<NewsResource>> =
flow {
emit(
datasource
.getNewsResources()
.filter {
it.authors.intersect(filterAuthorIds).isNotEmpty() ||
it.topics.intersect(filterTopicIds).isNotEmpty()
}
.filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
)
}.flowOn(ioDispatcher)

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.data.repository.fake
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.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
@ -26,7 +25,7 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
/**
* Fake implementation of the [AuthorsRepository] that returns hardcoded authors.
* Fake implementation of the [UserDataRepository] that returns hardcoded user data.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
@ -44,14 +43,6 @@ class FakeUserDataRepository @Inject constructor(
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
niaPreferencesDataSource.setFollowedAuthorIds(followedAuthorIds)
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
}

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -27,23 +26,6 @@ import org.junit.Test
class NetworkEntityKtTest {
@Test
fun network_author_can_be_mapped_to_author_entity() {
val networkModel = NetworkAuthor(
id = "0",
name = "Test",
imageUrl = "something",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("Test", entity.name)
assertEquals("something", entity.imageUrl)
}
@Test
fun network_topic_can_be_mapped_to_topic_entity() {
val networkModel = NetworkTopic(

@ -1,180 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestAuthorDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class OfflineFirstAuthorsRepositoryTest {
private lateinit var subject: OfflineFirstAuthorsRepository
private lateinit var authorDao: AuthorDao
private lateinit var network: TestNiaNetworkDataSource
private lateinit var synchronizer: Synchronizer
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before
fun setup() {
authorDao = TestAuthorDao()
network = TestNiaNetworkDataSource()
val niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
)
synchronizer = TestSynchronizer(niaPreferencesDataSource)
subject = OfflineFirstAuthorsRepository(
authorDao = authorDao,
network = network,
)
}
@Test
fun offlineFirstAuthorsRepository_Authors_stream_is_backed_by_Authors_dao() =
runTest {
assertEquals(
authorDao.getAuthorEntities()
.first()
.map(AuthorEntity::asExternalModel),
subject.getAuthors()
.first()
)
}
@Test
fun offlineFirstAuthorsRepository_sync_pulls_from_network() =
runTest {
subject.syncWith(synchronizer)
val networkAuthors = network.getAuthors()
.map(NetworkAuthor::asEntity)
val dbAuthors = authorDao.getAuthorEntities()
.first()
assertEquals(
networkAuthors.map(AuthorEntity::id),
dbAuthors.map(AuthorEntity::id)
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Authors),
synchronizer.getChangeListVersions().authorVersion
)
}
@Test
fun offlineFirstAuthorsRepository_incremental_sync_pulls_from_network() =
runTest {
// Set author version to 5
synchronizer.updateChangeListVersions {
copy(authorVersion = 5)
}
subject.syncWith(synchronizer)
val changeList = network.changeListsAfter(
CollectionType.Authors,
version = 5
)
val changeListIds = changeList
.map(NetworkChangeList::id)
.toSet()
val network = network.getAuthors()
.map(NetworkAuthor::asEntity)
.filter { it.id in changeListIds }
val db = authorDao.getAuthorEntities()
.first()
assertEquals(
network.map(AuthorEntity::id),
db.map(AuthorEntity::id)
)
// After sync version should be updated
assertEquals(
changeList.last().changeListVersion,
synchronizer.getChangeListVersions().authorVersion
)
}
@Test
fun offlineFirstAuthorsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest {
val networkAuthors = network.getAuthors()
.map(NetworkAuthor::asEntity)
.map(AuthorEntity::asExternalModel)
// Delete half of the items on the network
val deletedItems = networkAuthors
.map(Author::id)
.partition { it.chars().sum() % 2 == 0 }
.first
.toSet()
deletedItems.forEach {
network.editCollection(
collectionType = CollectionType.Authors,
id = it,
isDelete = true
)
}
subject.syncWith(synchronizer)
val dbAuthors = authorDao.getAuthorEntities()
.first()
.map(AuthorEntity::asExternalModel)
// Assert that items marked deleted on the network have been deleted locally
assertEquals(
networkAuthors.map(Author::id) - deletedItems,
dbAuthors.map(Author::id)
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Authors),
synchronizer.getChangeListVersions().authorVersion
)
}
}

@ -18,18 +18,14 @@ package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestAuthorDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds
import com.google.samples.apps.nowinandroid.core.data.testdoubles.nonPresentInterestsIds
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -53,8 +49,6 @@ class OfflineFirstNewsRepositoryTest {
private lateinit var newsResourceDao: TestNewsResourceDao
private lateinit var authorDao: TestAuthorDao
private lateinit var topicDao: TestTopicDao
private lateinit var network: TestNiaNetworkDataSource
@ -67,7 +61,6 @@ class OfflineFirstNewsRepositoryTest {
@Before
fun setup() {
newsResourceDao = TestNewsResourceDao()
authorDao = TestAuthorDao()
topicDao = TestTopicDao()
network = TestNiaNetworkDataSource()
synchronizer = TestSynchronizer(
@ -78,7 +71,6 @@ class OfflineFirstNewsRepositoryTest {
subject = OfflineFirstNewsRepository(
newsResourceDao = newsResourceDao,
authorDao = authorDao,
topicDao = topicDao,
network = network,
)
@ -120,30 +112,6 @@ class OfflineFirstNewsRepositoryTest {
)
}
@Test
fun offlineFirstNewsRepository_news_resources_for_author_is_backed_by_news_resource_dao() =
runTest {
assertEquals(
newsResourceDao.getNewsResources(
filterAuthorIds = filteredInterestsIds
)
.first()
.map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources(
filterAuthorIds = filteredInterestsIds
)
.first()
)
assertEquals(
emptyList(),
subject.getNewsResources(
filterAuthorIds = nonPresentInterestsIds
)
.first()
)
}
@Test
fun offlineFirstNewsRepository_sync_pulls_from_network() =
runTest {
@ -264,21 +232,6 @@ class OfflineFirstNewsRepositoryTest {
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_shell_author_entities() =
runTest {
subject.syncWith(synchronizer)
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::authorEntityShells)
.flatten()
.distinctBy(AuthorEntity::id),
authorDao.getAuthorEntities()
.first()
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
runTest {
@ -292,18 +245,4 @@ class OfflineFirstNewsRepositoryTest {
newsResourceDao.topicCrossReferences
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_author_cross_references() =
runTest {
subject.syncWith(synchronizer)
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::authorCrossReferences)
.distinct()
.flatten(),
newsResourceDao.authorCrossReferences
)
}
}

@ -58,7 +58,6 @@ class OfflineFirstUserDataRepositoryTest {
UserData(
bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false

@ -1,70 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/**
* Test double for [AuthorDao]
*/
class TestAuthorDao : AuthorDao {
private var entitiesStateFlow = MutableStateFlow(
listOf(
AuthorEntity(
id = "1",
name = "Topic",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
)
)
override fun getAuthorEntities(): Flow<List<AuthorEntity>> =
entitiesStateFlow
override fun getAuthorEntity(authorId: String): Flow<AuthorEntity> {
throw NotImplementedError("Unused in tests")
}
override suspend fun insertOrIgnoreAuthors(authorEntities: List<AuthorEntity>): List<Long> {
entitiesStateFlow.value = authorEntities
// Assume no conflicts on insert
return authorEntities.map { it.id.toLong() }
}
override suspend fun updateAuthors(entities: List<AuthorEntity>) {
throw NotImplementedError("Unused in tests")
}
override suspend fun upsertAuthors(entities: List<AuthorEntity>) {
entitiesStateFlow.value = entities
}
override suspend fun deleteAuthors(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->
entities.filterNot { idSet.contains(it.id) }
}
}
}

@ -17,8 +17,6 @@
package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
@ -54,22 +52,18 @@ class TestNewsResourceDao : NewsResourceDao {
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
internal var authorCrossReferences: List<NewsResourceAuthorCrossRef> = listOf()
override fun getNewsResources(): Flow<List<PopulatedNewsResource>> =
entitiesStateFlow.map {
it.map(NewsResourceEntity::asPopulatedNewsResource)
}
override fun getNewsResources(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>
): Flow<List<PopulatedNewsResource>> =
getNewsResources()
.map { resources ->
resources.filter { resource ->
resource.topics.any { it.id in filterTopicIds } ||
resource.authors.any { it.id in filterAuthorIds }
resource.topics.any { it.id in filterTopicIds }
}
}
@ -95,12 +89,6 @@ class TestNewsResourceDao : NewsResourceDao {
topicCrossReferences = newsResourceTopicCrossReferences
}
override suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
) {
authorCrossReferences = newsResourceAuthorCrossReferences
}
override suspend fun deleteNewsResources(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->
@ -111,16 +99,6 @@ class TestNewsResourceDao : NewsResourceDao {
private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource(
entity = this,
authors = listOf(
AuthorEntity(
id = "id",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf(
TopicEntity(
id = filteredInterestsIds.random(),

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -28,7 +27,6 @@ import kotlinx.serialization.json.Json
enum class CollectionType {
Topics,
Authors,
NewsResources
}
@ -37,19 +35,18 @@ enum class CollectionType {
*/
class TestNiaNetworkDataSource : NiaNetworkDataSource {
private val source = FakeNiaNetworkDataSource(UnconfinedTestDispatcher(), Json)
private val source = FakeNiaNetworkDataSource(
UnconfinedTestDispatcher(),
Json { ignoreUnknownKeys = true }
)
private val allTopics = runBlocking { source.getTopics() }
private val allAuthors = runBlocking { source.getAuthors() }
private val allNewsResources = runBlocking { source.getNewsResources() }
private val changeLists: MutableMap<CollectionType, List<NetworkChangeList>> = mutableMapOf(
CollectionType.Topics to allTopics
.mapToChangeList(idGetter = NetworkTopic::id),
CollectionType.Authors to allAuthors
.mapToChangeList(idGetter = NetworkAuthor::id),
CollectionType.NewsResources to allNewsResources
.mapToChangeList(idGetter = NetworkNewsResource::id),
)
@ -60,12 +57,6 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
idGetter = NetworkTopic::id
)
override suspend fun getAuthors(ids: List<String>?): List<NetworkAuthor> =
allAuthors.matchIds(
ids = ids,
idGetter = NetworkAuthor::id
)
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds(
ids = ids,
@ -75,9 +66,6 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.Topics).after(after)
override suspend fun getAuthorChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.Authors).after(after)
override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.NewsResources).after(after)

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.database.model
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
@ -37,16 +36,6 @@ class PopulatedNewsResourceKtTest {
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
),
authors = listOf(
AuthorEntity(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf(
TopicEntity(
id = "3",
@ -69,16 +58,6 @@ class PopulatedNewsResourceKtTest {
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
authors = listOf(
Author(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf(
Topic(
id = "3",

@ -0,0 +1,192 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "f83b94b22ba0a0ce640922a3475e7c3e",
"entities": [
{
"tableName": "news_resources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "headerImageUrl",
"columnName": "header_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "publishDate",
"columnName": "publish_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "news_resources_topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topicId",
"columnName": "topic_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"news_resource_id",
"topic_id"
]
},
"indices": [
{
"name": "index_news_resources_topics_news_resource_id",
"unique": false,
"columnNames": [
"news_resource_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)"
},
{
"name": "index_news_resources_topics_topic_id",
"unique": false,
"columnNames": [
"topic_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)"
}
],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "topics",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"topic_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "longDescription",
"columnName": "longDescription",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "imageUrl",
"columnName": "imageUrl",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f83b94b22ba0a0ce640922a3475e7c3e')"
]
}
}

@ -20,8 +20,6 @@ import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -38,7 +36,6 @@ class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao
private lateinit var topicDao: TopicDao
private lateinit var authorDao: AuthorDao
private lateinit var db: NiaDatabase
@Before
@ -50,7 +47,6 @@ class NewsResourceDaoTest {
).build()
newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao()
authorDao = db.authorDao()
}
@Test
@ -147,142 +143,6 @@ class NewsResourceDaoTest {
)
}
@Test
fun newsResourceDao_filters_items_by_author_ids_by_descending_publish_date() = runTest {
val authorEntities = listOf(
testAuthorEntity(
id = "1",
name = "1"
),
testAuthorEntity(
id = "2",
name = "2"
),
)
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
)
val newsResourceAuthorCrossRefEntities = authorEntities.mapIndexed { index, authorEntity ->
NewsResourceAuthorCrossRef(
newsResourceId = index.toString(),
authorId = authorEntity.id
)
}
authorDao.upsertAuthors(authorEntities)
newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities)
val filteredNewsResources = newsResourceDao.getNewsResources(
filterAuthorIds = authorEntities
.map(AuthorEntity::id)
.toSet()
).first()
assertEquals(
listOf("1", "0"),
filteredNewsResources.map { it.entity.id }
)
}
@Test
fun newsResourceDao_filters_items_by_topic_ids_or_author_ids_by_descending_publish_date() =
runTest {
val topicEntities = listOf(
testTopicEntity(
id = "1",
name = "1"
),
testTopicEntity(
id = "2",
name = "2"
),
)
val authorEntities = listOf(
testAuthorEntity(
id = "1",
name = "1"
),
testAuthorEntity(
id = "2",
name = "2"
),
)
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
// Should be missing as no topics or authors match it
testNewsResource(
id = "4",
millisSinceEpoch = 10,
),
)
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef(
newsResourceId = index.toString(),
topicId = topicEntity.id
)
}
val newsResourceAuthorCrossRefEntities =
authorEntities.mapIndexed { index, authorEntity ->
NewsResourceAuthorCrossRef(
// Offset news resources by two
newsResourceId = (index + 2).toString(),
authorId = authorEntity.id
)
}
topicDao.upsertTopics(topicEntities)
authorDao.upsertAuthors(authorEntities)
newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(newsResourceTopicCrossRefEntities)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities)
val filteredNewsResources = newsResourceDao.getNewsResources(
filterTopicIds = topicEntities
.map(TopicEntity::id)
.toSet(),
filterAuthorIds = authorEntities
.map(AuthorEntity::id)
.toSet()
).first()
assertEquals(
listOf("1", "3", "2", "0"),
filteredNewsResources.map { it.entity.id }
)
}
@Test
fun newsResourceDao_deletes_items_by_ids() =
runTest {
@ -322,18 +182,6 @@ class NewsResourceDaoTest {
}
}
private fun testAuthorEntity(
id: String = "0",
name: String
) = AuthorEntity(
id = id,
name = name,
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private fun testTopicEntity(
id: String = "0",
name: String

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.database
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import dagger.Module
@ -27,11 +26,6 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object DaosModule {
@Provides
fun providesAuthorDao(
database: NiaDatabase,
): AuthorDao = database.authorDao()
@Provides
fun providesTopicsDao(
database: NiaDatabase,

@ -50,4 +50,14 @@ object DatabaseMigrations {
)
)
class Schema10to11 : AutoMigrationSpec
@DeleteTable.Entries(
DeleteTable(
tableName = "news_resources_authors"
),
DeleteTable(
tableName = "authors"
)
)
class Schema11to12 : AutoMigrationSpec
}

@ -20,11 +20,8 @@ import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -33,13 +30,11 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
@Database(
entities = [
AuthorEntity::class,
NewsResourceAuthorCrossRef::class,
NewsResourceEntity::class,
NewsResourceTopicCrossRef::class,
TopicEntity::class,
],
version = 11,
version = 12,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
@ -50,7 +45,8 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class)
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class)
],
exportSchema = true,
)
@ -60,6 +56,5 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
)
abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao
abstract fun authorDao(): AuthorDao
abstract fun newsResourceDao(): NewsResourceDao
}

@ -1,72 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [AuthorEntity] access
*/
@Dao
interface AuthorDao {
@Query(
value = """
SELECT * FROM authors
WHERE id = :authorId
"""
)
fun getAuthorEntity(authorId: String): Flow<AuthorEntity>
@Query(value = "SELECT * FROM authors")
fun getAuthorEntities(): Flow<List<AuthorEntity>>
/**
* Inserts [authorEntities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreAuthors(authorEntities: List<AuthorEntity>): List<Long>
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateAuthors(entities: List<AuthorEntity>)
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Upsert
suspend fun upsertAuthors(entities: List<AuthorEntity>)
/**
* Deletes rows in the db matching the specified [ids]
*/
@Query(
value = """
DELETE FROM authors
WHERE id in (:ids)
"""
)
suspend fun deleteAuthors(ids: List<String>)
}

@ -23,7 +23,6 @@ import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
@ -53,16 +52,10 @@ interface NewsResourceDao {
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds)
)
OR id in
(
SELECT news_resource_id FROM news_resources_authors
WHERE author_id IN (:filterAuthorIds)
)
ORDER BY publish_date DESC
"""
)
fun getNewsResources(
filterAuthorIds: Set<String> = emptySet(),
filterTopicIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>>
@ -89,11 +82,6 @@ interface NewsResourceDao {
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>
)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
)
/**
* Deletes rows in the db matching the specified [ids]
*/

@ -1,52 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.google.samples.apps.nowinandroid.core.model.data.Author
/**
* Defines an author for [NewsResourceEntity].
* It has a many to many relationship with both entities
*/
@Entity(
tableName = "authors",
)
data class AuthorEntity(
@PrimaryKey
val id: String,
val name: String,
@ColumnInfo(name = "image_url")
val imageUrl: String,
@ColumnInfo(defaultValue = "")
val twitter: String,
@ColumnInfo(name = "medium_page", defaultValue = "")
val mediumPage: String,
@ColumnInfo(defaultValue = "")
val bio: String,
)
fun AuthorEntity.asExternalModel() = Author(
id = id,
name = name,
imageUrl = imageUrl,
twitter = twitter,
mediumPage = mediumPage,
bio = bio,
)

@ -1,54 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
/**
* Cross reference for many to many relationship between [NewsResourceEntity] and [AuthorEntity]
*/
@Entity(
tableName = "news_resources_authors",
primaryKeys = ["news_resource_id", "author_id"],
foreignKeys = [
ForeignKey(
entity = NewsResourceEntity::class,
parentColumns = ["id"],
childColumns = ["news_resource_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = AuthorEntity::class,
parentColumns = ["id"],
childColumns = ["author_id"],
onDelete = ForeignKey.CASCADE
),
],
indices = [
Index(value = ["news_resource_id"]),
Index(value = ["author_id"]),
],
)
data class NewsResourceAuthorCrossRef(
@ColumnInfo(name = "news_resource_id")
val newsResourceId: String,
@ColumnInfo(name = "author_id")
val authorId: String,
)

@ -50,6 +50,5 @@ fun NewsResourceEntity.asExternalModel() = NewsResource(
headerImageUrl = headerImageUrl,
publishDate = publishDate,
type = type,
authors = listOf(),
topics = listOf()
)

@ -27,16 +27,6 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
data class PopulatedNewsResource(
@Embedded
val entity: NewsResourceEntity,
@Relation(
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(
value = NewsResourceAuthorCrossRef::class,
parentColumn = "news_resource_id",
entityColumn = "author_id",
)
)
val authors: List<AuthorEntity>,
@Relation(
parentColumn = "id",
entityColumn = "id",
@ -57,6 +47,5 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource(
headerImageUrl = entity.headerImageUrl,
publishDate = entity.publishDate,
type = entity.type,
authors = authors.map(AuthorEntity::asExternalModel),
topics = topics.map(TopicEntity::asExternalModel)
)

@ -21,6 +21,5 @@ package com.google.samples.apps.nowinandroid.core.datastore
*/
data class ChangeListVersions(
val topicVersion: Int = -1,
val authorVersion: Int = -1,
val newsResourceVersion: Int = -1,
)

@ -35,7 +35,6 @@ class NiaPreferencesDataSource @Inject constructor(
UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,
followedTopics = it.followedTopicIdsMap.keys,
followedAuthors = it.followedAuthorIdsMap.keys,
themeBrand = when (it.themeBrand) {
null,
ThemeBrandProto.THEME_BRAND_UNSPECIFIED,
@ -88,37 +87,6 @@ class NiaPreferencesDataSource @Inject constructor(
}
}
suspend fun setFollowedAuthorIds(authorIds: Set<String>) {
try {
userPreferences.updateData {
it.copy {
followedAuthorIds.clear()
followedAuthorIds.putAll(authorIds.associateWith { true })
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
suspend fun toggleFollowedAuthorId(authorId: String, followed: Boolean) {
try {
userPreferences.updateData {
it.copy {
if (followed) {
followedAuthorIds.put(authorId, true)
} else {
followedAuthorIds.remove(authorId)
}
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
suspend fun setThemeBrand(themeBrand: ThemeBrand) {
userPreferences.updateData {
it.copy {
@ -163,7 +131,6 @@ class NiaPreferencesDataSource @Inject constructor(
.map {
ChangeListVersions(
topicVersion = it.topicChangeListVersion,
authorVersion = it.authorChangeListVersion,
newsResourceVersion = it.newsResourceChangeListVersion,
)
}
@ -178,14 +145,12 @@ class NiaPreferencesDataSource @Inject constructor(
val updatedChangeListVersions = update(
ChangeListVersions(
topicVersion = currentPreferences.topicChangeListVersion,
authorVersion = currentPreferences.authorChangeListVersion,
newsResourceVersion = currentPreferences.newsResourceChangeListVersion
)
)
currentPreferences.copy {
topicChangeListVersion = updatedChangeListVersions.topicVersion
authorChangeListVersion = updatedChangeListVersions.authorVersion
newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion
}
}

@ -50,20 +50,6 @@ class NiaPreferencesDataSourceTest {
assertTrue(subject.userData.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsLastAuthor_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting a single author.
subject.toggleFollowedAuthorId("1", true)
subject.setShouldHideOnboarding(true)
// When: they unfollow that author.
subject.toggleFollowedAuthorId("1", false)
// Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest {
@ -78,20 +64,6 @@ class NiaPreferencesDataSourceTest {
assertFalse(subject.userData.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllAuthors_shouldHideOnboardingIsFalse() = runTest {
// Given: user completes onboarding by selecting several authors.
subject.setFollowedAuthorIds(setOf("1", "2"))
subject.setShouldHideOnboarding(true)
// When: they unfollow those authors.
subject.setFollowedAuthorIds(emptySet())
// Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest {
@ -105,34 +77,4 @@ class NiaPreferencesDataSourceTest {
// Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllTopicsButNotAuthors_shouldHideOnboardingIsTrue() =
runTest {
// Given: user completes onboarding by selecting several topics and authors.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setFollowedAuthorIds(setOf("3", "4"))
subject.setShouldHideOnboarding(true)
// When: they unfollow just the topics.
subject.setFollowedTopicIds(emptySet())
// Then: onboarding should still be dismissed
assertTrue(subject.userData.first().shouldHideOnboarding)
}
@Test
fun userShouldHideOnboarding_unfollowsAllAuthorsButNotTopics_shouldHideOnboardingIsTrue() =
runTest {
// Given: user completes onboarding by selecting several topics and authors.
subject.setFollowedTopicIds(setOf("1", "2"))
subject.setFollowedAuthorIds(setOf("3", "4"))
subject.setShouldHideOnboarding(true)
// When: they unfollow just the authors.
subject.setFollowedAuthorIds(emptySet())
// Then: onboarding should still be dismissed
assertTrue(subject.userData.first().shouldHideOnboarding)
}
}

@ -40,26 +40,18 @@ class GetSaveableNewsResourcesUseCase @Inject constructor(
}
/**
* Returns a list of SaveableNewsResources which match the supplied set of topic ids or author
* ids.
* Returns a list of SaveableNewsResources which match the supplied set of topic 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.
*
* this is empty the list of news resources will not be filtered.
*/
operator fun invoke(
filterTopicIds: Set<String> = emptySet(),
filterAuthorIds: Set<String> = emptySet()
filterTopicIds: Set<String> = emptySet()
): Flow<List<SaveableNewsResource>> =
if (filterTopicIds.isEmpty() && filterAuthorIds.isEmpty()) {
if (filterTopicIds.isEmpty()) {
newsRepository.getNewsResources()
} else {
newsRepository.getNewsResources(
filterTopicIds = filterTopicIds,
filterAuthorIds = filterAuthorIds
)
newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
}.mapToSaveableNewsResources(bookmarkedNewsResources)
}

@ -1,50 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
/**
* A use case which obtains a list of authors sorted alphabetically by name with their followed
* state.
*/
class GetSortedFollowableAuthorsUseCase @Inject constructor(
private val authorsRepository: AuthorsRepository,
private val userDataRepository: UserDataRepository
) {
/**
* Returns a list of authors with their associated followed state sorted alphabetically by name.
*/
operator fun invoke(): Flow<List<FollowableAuthor>> =
combine(
authorsRepository.getAuthors(),
userDataRepository.userData
) { authors, userData ->
authors.map { author ->
FollowableAuthor(
author = author,
isFollowed = author.id in userData.followedAuthors
)
}
.sortedBy { it.author.name }
}
}

@ -1,27 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.Author
/**
* An [author] with the additional information for whether or not it is followed.
*/
data class FollowableAuthor(
val author: Author,
val isFollowed: Boolean
)

@ -17,7 +17,6 @@
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
@ -82,47 +81,6 @@ class GetSaveableNewsResourcesUseCaseTest {
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(
@ -143,26 +101,6 @@ private val sampleTopic2 = Topic(
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",
@ -175,8 +113,7 @@ private val sampleNewsResources = listOf(
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)
topics = listOf(sampleTopic1)
),
NewsResource(
id = "2",
@ -188,8 +125,7 @@ private val sampleNewsResources = listOf(
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)
topics = listOf(sampleTopic1, sampleTopic2)
),
NewsResource(
id = "3",
@ -199,7 +135,6 @@ private val sampleNewsResources = listOf(
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)
topics = listOf(sampleTopic2)
),
)

@ -1,97 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class GetSortedFollowableAuthorsUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val authorsRepository = TestAuthorsRepository()
private val userDataRepository = TestUserDataRepository()
val useCase = GetSortedFollowableAuthorsUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
@Test
fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest {
// Specify some authors which the user is following.
userDataRepository.setFollowedAuthorIds(setOf(sampleAuthor1.id))
// Obtain the stream of authors, specifying their followed state.
val followableAuthors = useCase()
// Supply some authors.
authorsRepository.sendAuthors(sampleAuthors)
// Check that the authors have been sorted, and that the followed state is correct.
assertEquals(
followableAuthors.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)

@ -1,50 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.model.data
/* ktlint-disable max-line-length */
/**
* External data layer representation of an NiA Author
*/
data class Author(
val id: String,
val name: String,
val imageUrl: String,
val twitter: String,
val mediumPage: String,
val bio: String,
)
val previewAuthors = listOf(
Author(
id = "22",
name = "Alex Vanyo",
mediumPage = "https://medium.com/@alexvanyo",
twitter = "https://twitter.com/alex_vanyo",
imageUrl = "https://pbs.twimg.com/profile_images/1431339735931305989/nOE2mmi2_400x400.jpg",
bio = "Alex joined Android DevRel in 2021, and has worked supporting form factors from small watches to large foldables and tablets. His special interests include insets, Compose, testing and state."
),
Author(
id = "3",
name = "Simona Stojanovic",
mediumPage = "https://medium.com/@anomisSi",
twitter = "https://twitter.com/anomisSi",
imageUrl = "https://pbs.twimg.com/profile_images/1437506849016778756/pG0NZALw_400x400.jpg",
bio = "Android Developer Relations Engineer @Google, working on the Compose team and taking care of Layouts & Navigation."
)
)

@ -36,7 +36,6 @@ data class NewsResource(
val headerImageUrl: String?,
val publishDate: Instant,
val type: NewsResourceType,
val authors: List<Author>,
val topics: List<Topic>
)
@ -47,7 +46,6 @@ val previewNewsResources = listOf(
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
authors = listOf(previewAuthors[0]),
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,
@ -71,7 +69,6 @@ val previewNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
authors = listOf(previewAuthors[1]),
topics = listOf(previewTopics[0], previewTopics[1])
),
NewsResource(
@ -85,7 +82,6 @@ val previewNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
authors = listOf(previewAuthors[0], previewAuthors[1]),
topics = listOf(previewTopics[2])
)
)

@ -22,7 +22,6 @@ package com.google.samples.apps.nowinandroid.core.model.data
data class UserData(
val bookmarkedNewsResources: Set<String>,
val followedTopics: Set<String>,
val followedAuthors: Set<String>,
val themeBrand: ThemeBrand,
val darkThemeConfig: DarkThemeConfig,
val shouldHideOnboarding: Boolean

@ -1,794 +0,0 @@
[
{
"id": "1",
"name": "Márton Braun",
"mediumPage": "https://medium.com/@zsmb13",
"twitter": "https://twitter.com/zsmb13",
"imageUrl": "https://pbs.twimg.com/profile_images/1047591879431397377/nNjQUt0F_400x400.jpg",
"bio": "Márton is an Android Developer Relations Engineer at Google, working on anything and everything Kotlin."
},
{
"id": "2",
"name": "Greg Hartrell",
"mediumPage": "https://medium.com/@greghart/about",
"twitter": "https://twitter.com/ghartrell",
"imageUrl": "https://pbs.twimg.com/profile_images/971602488984940547/plY3bBRz_400x400.jpg",
"bio": "Greg Hartrell is a product leader with a 15+ year history helping large teams build high performing software products and businesses. At Google, hes a Product Management Director at Google Play / Android, covering product lines from games, to digital content and platform expansion. Hes previously been VP of Product Development at Capcom/Beeline, and a product leader for 8 years at Microsoft for Xbox Live/360 and the Windows platform. When hes not speaking, he enjoys looting random objects out of boxes, jumping from platform to platform, and grinding while afk."
},
{
"id": "3",
"name": "Simona Stojanovic",
"mediumPage": "https://medium.com/@anomisSi",
"twitter": "https://twitter.com/anomisSi",
"imageUrl": "https://pbs.twimg.com/profile_images/1437506849016778756/pG0NZALw_400x400.jpg",
"bio": "Android Developer Relations Engineer @Google, working on the Compose team and taking care of Layouts & Navigation."
},
{
"id": "4",
"name": "Andrew Flynn",
"mediumPage": "",
"twitter": "",
"imageUrl": "https://lh3.googleusercontent.com/xfI5PujnEqdQ4GlsRZAvVOGiC_v3VTz6wYM8kxaPyOtXIZY4-BDYOr-d-cjN8kxAkr4yAthuWu2gTZ7t-do=s1016-rw-no",
"bio": "Andrew joined Google in 2007 after graduating from Dartmouth College. He has worked on a wide range of team from Ads to Android devices to Google Fi, and currently works on the Play Store."
},
{
"id": "5",
"name": "Jon Boekenoogen",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "6",
"name": "Florina Muntenescu",
"mediumPage": "https://medium.com/@florina.muntenescu",
"twitter": "https://twitter.com/FMuntenescu",
"imageUrl": "https://pbs.twimg.com/profile_images/726323972686503937/nZkTQVQJ_400x400.jpg",
"bio": "Florina is working as an Android Developer Relations Engineer at Google, helping developers build beautiful apps with Jetpack Compose. She has been working with Android for more than 10 years, previous work covering news at upday, payment solutions at payleven and navigation services at Garmin."
},
{
"id": "7",
"name": "Lidia Gaymond",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "8",
"name": "Vicki Amin",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "9",
"name": "Marcel Pintó",
"mediumPage": "https://medium.com/@marxallski",
"twitter": "https://twitter.com/marxallski",
"imageUrl": "https://pbs.twimg.com/profile_images/1196804242310234112/6pq8qguX_400x400.jpg",
"bio": ""
},
{
"id": "10",
"name": "Krish Vitaldevara",
"mediumPage": "",
"twitter": "https://twitter.com/vitaldevara",
"imageUrl": "https://pbs.twimg.com/profile_images/1326982232100122626/GDN-QoX-_400x400.png",
"bio": ""
},
{
"id": "11",
"name": "Gerry Fan",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "12",
"name": "Pietro Maggi",
"mediumPage": "https://medium.com/@pmaggi",
"twitter": "https://twitter.com/pfmaggi",
"imageUrl": "https://pbs.twimg.com/profile_images/1149328128868794370/T59BFarK_400x400.png",
"bio": "Pietro joined Android DevRel in 2018, and has worked supporting Jetpack WorkManager, large screen devices and lately, Android Enterprise."
},
{
"id": "13",
"name": "Rohan Shah",
"mediumPage": "",
"twitter": "twitter.com/rohanscloud",
"imageUrl": "https://pbs.twimg.com/profile_images/828723313396502530/dfVBFeZP_400x400.jpg",
"bio": "Product Manager on the System UI team, experienced Android developer in a past life. Focused on customization, gesture navigation, and smooth motion."
},
{
"id": "14",
"name": "Dave Burke",
"mediumPage": "",
"twitter": "https://twitter.com/davey_burke",
"imageUrl": "https://pbs.twimg.com/profile_images/1035017742825357312/f0IHVAG1_400x400.jpg",
"bio": ""
},
{
"id": "15",
"name": "Meghan Mehta",
"mediumPage": "https://medium.com/@magicalmeghan",
"twitter": "https://twitter.com/adressyengineer",
"imageUrl": "https://pbs.twimg.com/profile_images/1121847353214824449/wIB-dD0__400x400.jpg",
"bio": "Meghan has been in Google DevRel since 2018 and loves every minute of it. She mostly works on training and is passionate about making learning Android accessible to all. Outside of work you can find her singing, dancing, or petting dogs."
},
{
"id": "16",
"name": "Anna Bernbaum",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Anna Bernbaum is a Product Manager for Wear OS, she specifically focusses on Watch Faces, Complications and Tiles."
},
{
"id": "17",
"name": "Adarsh Fernando",
"mediumPage": "",
"twitter": "https://twitter.com/AdarshFernando",
"imageUrl": "https://pbs.twimg.com/profile_images/1291755351298121728/SFGD_KCm_400x400.jpg",
"bio": ""
},
{
"id": "18",
"name": "Madan Ankapura",
"mediumPage": "",
"twitter": "https://twitter.com/madan_ankapura",
"imageUrl": "https://pbs.twimg.com/profile_images/195140822/IMG_0612_400x400.JPG",
"bio": ""
},
{
"id": "19",
"name": "Kateryna Semenova",
"mediumPage": "https://medium.com/@katerynasemenova",
"twitter": "https://twitter.com/SKateryna",
"imageUrl": "https://miro.medium.com/fit/c/176/176/2*MWidJNpRKpwnPhMYw1hBTA.png",
"bio": "Kate joined Google Android DevRel team in 2020 with the focus on Android performance. She worked on Android12 features, such as Splash screens and App links as well as App startup improvements. Right now she is working on new Deep links tools for develoeprs.\n\nBefore Google, Kate worked as an Android engineer at Lyft. She built LyftPink membership, ride passes, subscriptions and users onboarding flow."
},
{
"id": "20",
"name": "Rahul Ravikumar",
"mediumPage": "https://medium.com/@rahulrav",
"twitter": "https://twitter.com/tikurahul",
"imageUrl": "https://pbs.twimg.com/profile_images/839866273160822785/sLb2Ld53_400x400.jpg",
"bio": "Software Engineer on the Toolkit team. Recent projects include Jetpack Macrobenchmarks, Baseline Profiles and WorkManager."
},
{
"id": "21",
"name": "Chris Craik",
"mediumPage": "https://medium.com/@chriscraik",
"twitter": "https://twitter.com/chris_craik",
"imageUrl": "https://pbs.twimg.com/profile_images/865356883971919872/EkFpz3r1_400x400.jpg",
"bio": ""
},
{
"id": "22",
"name": "Alex Vanyo",
"mediumPage": "https://medium.com/@alexvanyo",
"twitter": "https://twitter.com/alex_vanyo",
"imageUrl": "https://pbs.twimg.com/profile_images/1431339735931305989/nOE2mmi2_400x400.jpg",
"bio": "Alex joined Android DevRel in 2021, and has worked supporting form factors from small watches to large foldables and tablets. His special interests include insets, Compose, testing and state."
},
{
"id": "23",
"name": "Manuel Vivo",
"mediumPage": "https://medium.com/@manuelvicnt",
"twitter": "https://twitter.com/manuelvicnt",
"imageUrl": "https://pbs.twimg.com/profile_images/1126564755202760705/x3qLaiBB_400x400.jpg",
"bio": "Manuel is an Android Developer Relations Engineer at Google. With previous experience at Capital One, he currently focuses on App Architecture, Kotlin & Coroutines, Dependency Injection and Jetpack Compose."
},
{
"id": "24",
"name": "Arjun Dayal",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "25",
"name": "Murat Yener",
"mediumPage": "https://medium.com/@yenerm",
"twitter": "https://twitter.com/yenerm",
"imageUrl": "https://pbs.twimg.com/profile_images/1201558524385316866/7lBmCYmD_400x400.jpg",
"bio": ""
},
{
"id": "26",
"name": "Alex Saveau",
"mediumPage": "https://medium.com/@SUPERCILEX",
"twitter": "https://twitter.com/SUPERCILEX",
"imageUrl": "https://pbs.twimg.com/profile_images/1093296033759604736/kO9WSDy4_400x400.jpg",
"bio": ""
},
{
"id": "27",
"name": "Paul Lammertsma",
"mediumPage": "https://medium.com/@officesunshine",
"twitter": "https://twitter.com/officesunshine",
"imageUrl": "https://pbs.twimg.com/profile_images/1281278859506331651/2XBTbfIK_400x400.jpg",
"bio": "Paul is an Android Developer Relations Engineer at Google, supporting developers for TVs, watches and large screens. He has passionate about building amazing Android experiences since Froyo."
},
{
"id": "28",
"name": "Caren Chang",
"mediumPage": "https://medium.com/@calren24",
"twitter": "https://twitter.com/calren24",
"imageUrl": "https://pbs.twimg.com/profile_images/1521260707521519617/cW-G-T2R_400x400.jpg",
"bio": "Developer Relations Engineer on the Android team, with a focus on Accessibiliity. "
},
{
"id": "29",
"name": "Mayuri Khinvasara Khabya",
"mediumPage": "https://medium.com/@mayuri.k18",
"twitter": "https://twitter.com/mayuri_k",
"imageUrl": "https://pbs.twimg.com/profile_images/769886964170436608/6tsEB7zi_400x400.jpg",
"bio": "Mayuri is an Android Developer Relations Engineer and has worked across Android TV, App performance and device backup"
},
{
"id": "30",
"name": "Romain Guy",
"mediumPage": "https://medium.com/@romainguy",
"twitter": "https://twitter.com/romainguy",
"imageUrl": "https://pbs.twimg.com/profile_images/459175652105527298/6qGNL0QI_400x400.jpeg",
"bio": "Romain joined the Android team in 2007, working on the UI Toolkit and the Graphics pipeline for many years. He now leads the Toolkit and Jetpack teams."
},
{
"id": "31",
"name": "Chet Haase",
"mediumPage": "https://chethaase.medium.com/",
"twitter": "https://twitter.com/chethaase",
"imageUrl": "https://miro.medium.com/max/3150/1*5pR0GFT8Cosn_zGIdRfc0Q.jpeg",
"bio": "Chet joined the Android team in 2010, where he has worked as an engineer and lead for the UI Toolkit team, as Chief Android Advocate on the Developer Relations team, and now as an engineer on the Graphics team."
},
{
"id": "32",
"name": "Tor Norbye",
"mediumPage": "",
"twitter": "https://twitter.com/tornorbye",
"imageUrl": "https://pbs.twimg.com/profile_images/1411058065839845376/SeUHA-sR_400x400.jpg",
"bio": "Tor joined the Android team in 2010, and leads engineering for Android Studio."
},
{
"id": "33",
"name": "Nicole Laure",
"mediumPage": "",
"twitter": "https://twitter.com/nicolelaure",
"imageUrl": "https://pbs.twimg.com/profile_images/442979550285557760/0nq7QOTn_400x400.jpeg",
"bio": ""
},
{
"id": "34",
"name": "Yigit Boyar",
"mediumPage": "https://medium.com/@yigit",
"twitter": "https://twitter.com/yigitboyar",
"imageUrl": "https://pbs.twimg.com/profile_images/530840695347482624/me4HgEMU_400x400.jpeg",
"bio": "Yigit joined the Android team in 2014 and worked on various projects from RecyclerView to Architecture Components with the dream of making Android development better. Still trying :)"
},
{
"id": "35",
"name": "Sean McQuillan",
"mediumPage": "https://medium.com/@objcode",
"twitter": "https://twitter.com/objcode",
"imageUrl": "https://pbs.twimg.com/profile_images/913524063175286784/nhyO1wkU_400x400.jpg",
"bio": "Software Engineer on the Toolkit team. Working on making text 📜 sparkle ✨. Also helping melting face 🫠 render on your phone."
},
{
"id": "36",
"name": "Ben Weiss",
"mediumPage": "https://medium.com/@keyboardsurfer",
"twitter": "https://twitter.com/keyboardsurfer",
"imageUrl": "https://pbs.twimg.com/profile_images/1455956781830709255/GqeqbgEY_400x400.jpg",
"bio": "Ben works as an Engineer on the Android Developer Relations team. Over the years he contributed to many areas of Android. To get the latest info on what's up with Ben, read one of his articles or follow him on Twitter."
},
{
"id": "37",
"name": "Carmen Jackson",
"mediumPage": "",
"twitter": "",
"imageUrl": "https://media-exp1.licdn.com/dms/image/C5603AQE8wPl4iNUPeA/profile-displayphoto-shrink_200_200/0/1553574292680?e=1655337600&v=beta&t=6zsc9QaC95DM19uXbG3FJQwUIpObp4XeO-WsLzlNGIo",
"bio": "Carmen has been an engineer on the Android Platform Performance team since 2016 and has worked with Android for over 10 years. She currently leads a team focused on improving application performance."
},
{
"id": "38",
"name": "TJ Dahunsi",
"mediumPage": "https://tunjid.medium.com/",
"twitter": "",
"imageUrl": "https://pbs.twimg.com/profile_images/1504815529848152074/iA9Q_QME_400x400.jpg",
"bio": "Tj is an engineer on the Android Developer Relations Team working on app architecture. He's also a connoiseur of fine memes."
},
{
"id": "39",
"name": "Shailen Tuli",
"mediumPage": "",
"twitter": "https://twitter.com/shailentuli",
"imageUrl": "https://pbs.twimg.com/profile_images/1521593961/shailen_400x400.jpg",
"bio": ""
},
{
"id": "40",
"name": "Kailiang Chen",
"mediumPage": "https://medium.com/@bbfee",
"twitter": "https://twitter.com/KailiangChen3",
"imageUrl": "https://avatars.githubusercontent.com/u/1756481?v=4",
"bio": "Kailiang is a software engineer on Android Camera Platform team. He works on Jetpack CameraX. In the past, he worked at Youtube and Uber."
},
{
"id": "41",
"name": "Jeremy Walker",
"mediumPage": "https://medium.com/@codingjeremy",
"twitter": "https://twitter.com/codingjeremy",
"imageUrl": "https://pbs.twimg.com/profile_images/1124334569887428610/etnNE5hz_400x400.png",
"bio": ""
},
{
"id": "42",
"name": "Don Turner",
"mediumPage": "https://medium.com/@donturner",
"twitter": "https://twitter.com/donturner",
"imageUrl": "https://pbs.twimg.com/profile_images/1282701855555018753/xcnlScis_400x400.jpg",
"bio": "Don is an engineer on the Android Developer Relations team. He has founded and led several businesses in the software development, digital marketing and events industries. He joined Google in 2014 and focuses on improving Android app architecture. "
},
{
"id": "43",
"name": "Lilian Young",
"mediumPage": "",
"twitter": "https://twitter.com/memnus",
"imageUrl": "https://developer.android.com/events/dev-summit/images/speakers/lilian_young_720.jpg",
"bio": "Lilian has been part of the Android Vulnerability Rewards Program nearly from the beginning, since joining Android Security Assurance in 2016. As a member of both the vulnerability assessment team and the design review team for upcoming Android versions, they review bugs and features to determine which is which. "
},
{
"id": "44",
"name": "Wenhung Teng",
"mediumPage": "",
"twitter": "",
"imageUrl": "https://lh3.googleusercontent.com/a-/AOh14Ghsnwfup3BUugAxNwQsAD8Ph_CWNrH8SL6Wb8OZ",
"bio": ""
},
{
"id": "45",
"name": "Charcoal Chen",
"mediumPage": "https://medium.com/@charcoalchen",
"twitter": "",
"imageUrl": "https://miro.medium.com/fit/c/262/262/0*XgfVFjuchPekBYfh",
"bio": "Charcoal Chen is a software engineer on Jetpack CameraX team."
},
{
"id": "46",
"name": "Mike Yerou",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "47",
"name": "Peter Visontay",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "48",
"name": "Marcelo Hernandez",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "49",
"name": "Daniel Santiago",
"mediumPage": "https://medium.com/@danysantiago",
"twitter": "",
"imageUrl": "https://miro.medium.com/fit/c/262/262/2*fe7m2z-tRofWYjaiXihi9g.jpeg",
"bio": ""
},
{
"id": "50",
"name": "Brad Corso",
"mediumPage": "https://medium.com/@bcorso1",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "51",
"name": "Jonathan Koren",
"mediumPage": "https://medium.com/@jdkoren",
"twitter": "",
"imageUrl": "",
"bio": "Jonathan is an Android Developer Relations Engineer at Google, with particular interest in UI and app architecture. In the past, he developed Android applications and libraries at Yahoo and Personal Capital. In the future, he will have fixed the bugs he is writing in the present (hopefully)."
},
{
"id": "52",
"name": "Anna-Chiara Bellini",
"mediumPage": "",
"twitter": "https://twitter.com/dr0nequeen",
"imageUrl": "https://pbs.twimg.com/profile_images/852490449961066496/AxcrR7xX_400x400.jpg",
"bio": "Anna-Chiara joined Google in 2019 after a long career as a Computer Engineer and Startup founder. Now she focuses on making Android developers' lives easier. "
},
{
"id": "53",
"name": "Amanda Alexander",
"mediumPage": "",
"twitter": "",
"imageUrl": "https://developer.android.com/events/dev-summit/images/speakers/amanda_alexander_720.jpg",
"bio": ""
},
{
"id": "54",
"name": "Android Developers Backstage",
"mediumPage": "",
"twitter": "",
"imageUrl": "https://ssl-static.libsyn.com/p/assets/d/b/3/6/db362cf09b34bd4ce5bbc093207a2619/height_250_width_250_Android_Devs_Backstage_Thumb_v2.png",
"bio": "Android Developers Backstage is a podcast by and for Android developers. Hosted by developers from the Android platform team, this show covers topics of interest to Android programmers, with in-depth discussions and interviews with engineers on the Android team at Google."
},
{
"id": "55",
"name": "Nicole Borrelli",
"mediumPage": "https://medium.com/@borrelli",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "56",
"name": "Dan Saadati",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "57",
"name": "Nick Butcher",
"mediumPage": "https://medium.com/@crafty",
"twitter": "https://twitter.com/crafty",
"imageUrl": "https://pbs.twimg.com/profile_images/964457443517444097/AiMNiPtx_400x400.jpg",
"bio": ""
},
{
"id": "58",
"name": "Ian Lake",
"mediumPage": "https://medium.com/@ianhlake",
"twitter": "https://twitter.com/ianhlake",
"imageUrl": "https://pbs.twimg.com/profile_images/438932314504978434/uj3Sgwy9_400x400.jpeg",
"bio": "Ian is a software engineer on the Android Toolkit team at Google. He leads the teams working on the Navigation Component, Fragments, and the integration of fundamental building blocks such as Lifecycle, Saved State, and Activity APIs across Jetpack."
},
{
"id": "59",
"name": "Diana Wong",
"mediumPage": "",
"twitter": "https://twitter.com/droidiana1000",
"imageUrl": "https://pbs.twimg.com/profile_images/1268358805537951744/zCYvvpvV_400x400.jpg",
"bio": "Diana is a Product Manager on the Android Developer team, owning the Large Screen app ecosystem and loves Android foldables and tablets. Previously she's worked on Android Jetpack, the NDK, and the Android runtime (ART), among other things."
},
{
"id": "60",
"name": "Patricia Correa",
"mediumPage": "",
"twitter": "",
"imageUrl": "https://pbs.twimg.com/profile_images/940666196050944000/w12h2qOz_400x400.jpg",
"bio": ""
},
{
"id": "61",
"name": "The Modern Android Development Team",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Development tools, APIs, language, and distribution technologies recommended by the Android team to help developers be productive and create better apps that run across billions of devices."
},
{
"id": "62",
"name": "Maru Ahues Bouza",
"mediumPage": "https://medium.com/@mahues",
"twitter": "https://twitter.com/mabouza",
"imageUrl": "https://pbs.twimg.com/profile_images/1496362581253967872/4S4SBVYC_400x400.jpg",
"bio": "Director of Android Developer Relations @ Google. Venezolana 🇻🇪."
},
{
"id": "63",
"name": "Purnima Kochikar",
"mediumPage": "",
"twitter": "https://twitter.com/purnimakochikar",
"imageUrl": "https://media-exp1.licdn.com/dms/image/C4D03AQHUHmUGiioagQ/profile-displayphoto-shrink_800_800/0/1639519434507?e=2147483647&v=beta&t=OIt4yJkbJ7Suewlgyc7OrsLweMLBULRBvVHb9h4ZX5o",
"bio": "VP, Google Play, Apps & Games at Google"
},
{
"id": "64",
"name": "Yasmine Evjen",
"mediumPage": "https://yasmineevjen.medium.com/",
"twitter": "https://twitter.com/yasmineevjen",
"imageUrl": "https://miro.medium.com/fit/c/96/96/1*xK0hkXcG3TYOXDkdxorXOA.jpeg",
"bio": "Community Lead, Android Developer Relations"
},
{
"id": "65",
"name": "Jolanda Verhoef",
"mediumPage": "https://medium.com/@lojanda",
"twitter": "https://twitter.com/Lojanda",
"imageUrl": "https://pbs.twimg.com/profile_images/1396863889996980225/qBgkY5rY_400x400.jpg",
"bio": "Android Developer Relations Engineer @Google, focusing on Jetpack Compose 🚀"
},
{
"id": "66",
"name": "Rakesh Iyer",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Staff Software Engineer"
},
{
"id": "67",
"name": "Leland Rechis",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Group Product Manager"
},
{
"id": "68",
"name": "Chris Arriola",
"mediumPage": "https://medium.com/@arriolachris",
"twitter": "https://twitter.com/arriolachris",
"imageUrl": "https://pbs.twimg.com/profile_images/1392882006074093568/zITOTjRR_400x400.jpg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "69",
"name": "Ryan OLeary",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "70",
"name": "Wojtek Kaliciński",
"mediumPage": "https://medium.com/@wkalicinski",
"twitter": "https://twitter.com/wkalic",
"imageUrl": "https://pbs.twimg.com/profile_images/906094366388875264/RzDjkVh7_400x400.jpg",
"bio": "Android Developer Relations Engineer"
},
{
"id": "71",
"name": "Boris Farber",
"mediumPage": "",
"twitter": "https://twitter.com/BorisFarber",
"imageUrl": "",
"bio": ""
},
{
"id": "72",
"name": "Xavier Ducrohet",
"mediumPage": "",
"twitter": "https://twitter.com/droidxav",
"imageUrl": "",
"bio": ""
},
{
"id": "73",
"name": "Niharika Arora",
"mediumPage": "",
"twitter": "https://twitter.com/theDroidLady",
"imageUrl": "",
"bio": ""
},
{
"id": "74",
"name": "Marcus Leal",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": ""
},
{
"id": "75",
"name": "Kseniia Shumelchyk",
"mediumPage": "",
"twitter": "https://twitter.com/kseniiaS",
"imageUrl": "https://media-exp1.licdn.com/dms/image/C4D03AQGdVWXQeoKI3g/profile-displayphoto-shrink_800_800/0/1635165725069?e=2147483647&v=beta&t=lyf4j6SsqBidEZQZem5Ewi62QkqERPxZWVNByKeRMks",
"bio": "Developer Relations Engineer @Google focused on ⌚and 📱. Former Android @GoogleDevExpert, #GDG and @WomenTechmakers 🇺🇦."
},
{
"id": "76",
"name": "Tom Grinsted",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Group Product Manager, Google Play"
},
{
"id": "77",
"name": "Fred Chung",
"mediumPage": "",
"twitter": "https://twitter.com/fredchung",
"imageUrl": "https://pbs.twimg.com/profile_images/2670736355/1a3614dbe62d9ba31933626e273a3f77_400x400.jpeg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "78",
"name": "Kat Kuan",
"mediumPage": "",
"twitter": "https://twitter.com/katherine_kuan",
"imageUrl": "https://pbs.twimg.com/profile_images/1062503708964020224/gXpwDkOM_400x400.jpg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "79",
"name": "Summers Pitman",
"mediumPage": "https://medium.com/@mrsummers",
"twitter": "",
"imageUrl": "https://miro.medium.com/fit/c/176/176/0*8wWgPmCgxariqpFo",
"bio": ""
},
{
"id": "80",
"name": "Ben Trengrove",
"mediumPage": "https://medium.com/androiddevelopers/jetpack-compose-composition-tracing-9ec2b3aea535",
"twitter": "https://twitter.com/bentrengrove",
"imageUrl": "https://pbs.twimg.com/profile_images/1536488024661700609/zxdNBhWT_400x400.jpg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "81",
"name": "Takeshi Hagikura",
"mediumPage": "https://medium.com/@thagikura",
"twitter": "",
"imageUrl": "https://miro.medium.com/fit/c/176/176/1*esL5OPETU5LMXNO-8os7dw.jpeg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "82",
"name": "Miłosz Moczkowski",
"mediumPage": "https://medium.com/@m_moczkowski",
"twitter": "https://twitter.com/m_moczkowski?lang=en",
"imageUrl": "https://pbs.twimg.com/profile_images/1571116773612654592/sAJD-u-T_400x400.jpg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "83",
"name": "Sabs",
"mediumPage": "https://medium.com/@iamsabs",
"twitter": "",
"imageUrl": "",
"bio": "Developer Relations Engineer at Google"
},
{
"id": "84",
"name": "Alex Rocha",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Developer Relations Engineer at Google"
},
{
"id": "85",
"name": "Donovan McMurray",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "CameraX Developer Relations Engineer"
},
{
"id": "86",
"name": "Ataul Munim",
"mediumPage": "https://medium.com/@ataulm",
"twitter": "https://twitter.com/ataulm",
"imageUrl": "https://pbs.twimg.com/profile_images/1586614206803083266/BrGIIU23_400x400.jpg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "87",
"name": "Yafit Becher",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Google Play Product Manager at Google"
},
{
"id": "88",
"name": "Luis Dorelli",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Google Play Software Engineer at Google"
},
{
"id": "89",
"name": "Terence Zhang",
"mediumPage": "https://medium.com/@tzterencezhang",
"twitter": "",
"imageUrl": "",
"bio": "Developer Relations Engineer at Google"
},
{
"id": "90",
"name": "Roberto Orgiu",
"mediumPage": "https://tiwiz.medium.com/",
"twitter": "https://twitter.com/_tiwiz",
"imageUrl": "https://pbs.twimg.com/profile_images/1504459589877698562/CncWER1I_400x400.jpg",
"bio": "@AndroidDev Relations Engineer @Google | Former @nytimes | 🚴🚵📷 👨‍👩‍👧‍👦"
},
{
"id": "91",
"name": "Alejandra Stamato",
"mediumPage": "https://medium.com/@astamato",
"twitter": "https://twitter.com/astamatok",
"imageUrl": "https://developer.android.com/events/dev-summit/images/speakers/alejandra-stamato.jpg",
"bio": "Android Developer Relations Engineer 🥑 @Google #JetpackCompose | 🇦🇷 @ 🏴󠁧󠁢󠁥󠁮󠁧󠁿👑 | Ex 🇮🇪🍀"
},
{
"id": "92",
"name": "Jason Tang",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Product Manager at Google"
},
{
"id": "93",
"name": "Diego Zuluaga",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Developer Relations Engineer at Google"
},
{
"id": "94",
"name": "Michael Mauzy",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Developer Documentation at Google"
},
{
"id": "95",
"name": "Dan Galpin",
"mediumPage": "https://medium.com/@dagalpin",
"twitter": "https://twitter.com/dagalpin",
"imageUrl": "https://developer.android.com/events/dev-summit/images/speakers/daniel-galpin_720.jpg",
"bio": "Android Developer Relations Engineer at Google"
},
{
"id": "96",
"name": "Rebecca Franks",
"mediumPage": "https://medium.com/@riggaroo ",
"twitter": "https://twitter.com/riggaroo",
"imageUrl": "https://developer.android.com/events/dev-summit/images/speakers/rebecca-franks_720.jpg",
"bio": "Rebecca is a Developer Relations Engineer on the Android team. Having joined the team in 2022, she is focused on Jetpack Compose: all things animation and graphics. Prior to joining Google she worked on a couple of interesting projects such as a Image/Video editor and TV Streaming app. "
},
{
"id": "97",
"name": "Rebecca Gutteridge",
"mediumPage": "",
"twitter": "https://twitter.com/BexSG_",
"imageUrl": "https://twitter.com/BexSG_/photo",
"bio": "Senior Android Developer Relations Engineer at Google"
},
{
"id": "98",
"name": "Sachiyo Sugimoto",
"mediumPage": "",
"twitter": "",
"imageUrl": "",
"bio": "Android Partner Engineering"
},
{
"id": "99",
"name": "Todd Burner",
"mediumPage": "https://medium.com/@tburner",
"twitter": "",
"imageUrl": "",
"bio": "Developer Relations Engineer"
}
]

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.network
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -27,13 +26,9 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
interface NiaNetworkDataSource {
suspend fun getTopics(ids: List<String>? = null): List<NetworkTopic>
suspend fun getAuthors(ids: List<String>? = null): List<NetworkAuthor>
suspend fun getNewsResources(ids: List<String>? = null): List<NetworkNewsResource>
suspend fun getTopicChangeList(after: Int? = null): List<NetworkChangeList>
suspend fun getAuthorChangeList(after: Int? = null): List<NetworkChangeList>
suspend fun getNewsResourceChangeList(after: Int? = null): List<NetworkChangeList>
}

@ -20,7 +20,6 @@ import JvmUnitTestFakeAssetManager
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -58,18 +57,9 @@ class FakeNiaNetworkDataSource @Inject constructor(
assets.open(NEWS_ASSET).use(networkJson::decodeFromStream)
}
@OptIn(ExperimentalSerializationApi::class)
override suspend fun getAuthors(ids: List<String>?): List<NetworkAuthor> =
withContext(ioDispatcher) {
assets.open(AUTHORS_ASSET).use(networkJson::decodeFromStream)
}
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
getTopics().mapToChangeList(NetworkTopic::id)
override suspend fun getAuthorChangeList(after: Int?): List<NetworkChangeList> =
getAuthors().mapToChangeList(NetworkAuthor::id)
override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =
getNewsResources().mapToChangeList(NetworkNewsResource::id)
}

@ -1,33 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.network.model
import com.google.samples.apps.nowinandroid.core.model.data.Author
import kotlinx.serialization.Serializable
/**
* Network representation of [Author]
*/
@Serializable
data class NetworkAuthor(
val id: String,
val name: String,
val imageUrl: String,
val twitter: String,
val mediumPage: String,
val bio: String,
)

@ -37,7 +37,6 @@ data class NetworkNewsResource(
val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class)
val type: NewsResourceType,
val authors: List<String> = listOf(),
val topics: List<String> = listOf(),
)
@ -55,6 +54,5 @@ data class NetworkNewsResourceExpanded(
val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class)
val type: NewsResourceType,
val authors: List<NetworkAuthor> = listOf(),
val topics: List<NetworkTopic> = listOf(),
)

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.network.retrofit
import com.google.samples.apps.nowinandroid.core.network.BuildConfig
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -44,11 +43,6 @@ private interface RetrofitNiaNetworkApi {
@Query("id") ids: List<String>?,
): NetworkResponse<List<NetworkTopic>>
@GET(value = "authors")
suspend fun getAuthors(
@Query("id") ids: List<String>?,
): NetworkResponse<List<NetworkAuthor>>
@GET(value = "newsresources")
suspend fun getNewsResources(
@Query("id") ids: List<String>?,
@ -59,11 +53,6 @@ private interface RetrofitNiaNetworkApi {
@Query("after") after: Int?,
): List<NetworkChangeList>
@GET(value = "changelists/authors")
suspend fun getAuthorsChangeList(
@Query("after") after: Int?,
): List<NetworkChangeList>
@GET(value = "changelists/newsresources")
suspend fun getNewsResourcesChangeList(
@Query("after") after: Int?,
@ -110,18 +99,12 @@ class RetrofitNiaNetwork @Inject constructor(
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
networkApi.getTopics(ids = ids).data
override suspend fun getAuthors(ids: List<String>?): List<NetworkAuthor> =
networkApi.getAuthors(ids = ids).data
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
networkApi.getNewsResources(ids = ids).data
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
networkApi.getTopicChangeList(after = after)
override suspend fun getAuthorChangeList(after: Int?): List<NetworkChangeList> =
networkApi.getAuthorsChangeList(after = after)
override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =
networkApi.getNewsResourcesChangeList(after = after)
}

@ -72,7 +72,6 @@ class FakeNiaNetworkDataSourceTest {
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. ",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
authors = listOf("25"),
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,

@ -1,48 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.testing.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Author
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
class TestAuthorsRepository : AuthorsRepository {
/**
* The backing hot flow for the list of author ids for testing.
*/
private val authorsFlow: MutableSharedFlow<List<Author>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override fun getAuthors(): Flow<List<Author>> = authorsFlow
override fun getAuthor(id: String): Flow<Author> {
return authorsFlow.map { authors -> authors.find { it.id == id }!! }
}
override suspend fun syncWith(synchronizer: Synchronizer) = true
/**
* A test-only API to allow controlling the list of authors from tests.
*/
fun sendAuthors(authors: List<Author>) {
authorsFlow.tryEmit(authors)
}
}

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.testing.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
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.Topic
import kotlinx.coroutines.channels.BufferOverflow
@ -36,16 +35,11 @@ class TestNewsRepository : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> = newsResourcesFlow
override fun getNewsResources(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>
): Flow<List<NewsResource>> =
override fun getNewsResources(filterTopicIds: Set<String>): Flow<List<NewsResource>> =
getNewsResources().map { newsResources ->
newsResources
.filter {
it.authors.map(Author::id).intersect(filterAuthorIds).isNotEmpty() ||
it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
}
newsResources.filter {
it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
}
}
/**

@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.filterNotNull
private val emptyUserData = UserData(
bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false
@ -57,19 +56,6 @@ class TestUserDataRepository : UserDataRepository {
}
}
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
_userData.tryEmit(currentUserData.copy(followedAuthors = followedAuthorIds))
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
currentUserData.let { current ->
val followedAuthors = if (followed) current.followedAuthors + followedAuthorId
else current.followedAuthors - followedAuthorId
_userData.tryEmit(current.copy(followedAuthors = followedAuthors))
}
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
currentUserData.let { current ->
val bookmarkedNews = if (bookmarked) current.bookmarkedNewsResources + newsResourceId
@ -112,10 +98,4 @@ class TestUserDataRepository : UserDataRepository {
*/
fun getCurrentFollowedTopics(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedTopics
/**
* A test-only API to allow querying the current followed authors.
*/
fun getCurrentFollowedAuthors(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedAuthors
}

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -26,10 +25,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@ -44,9 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
@ -62,7 +56,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggl
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag
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.Author
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
@ -106,9 +99,6 @@ fun NewsResourceCardExpanded(
modifier = Modifier.padding(16.dp)
) {
Column {
Row {
NewsResourceAuthors(newsResource.authors)
}
Spacer(modifier = Modifier.height(12.dp))
Row {
NewsResourceTitle(
@ -151,45 +141,6 @@ fun NewsResourceHeaderImage(
)
}
@Composable
fun NewsResourceAuthors(
authors: List<Author>
) {
if (authors.isNotEmpty()) {
// display all authors
val authorNameFormatted =
authors.joinToString(separator = ", ") { author -> author.name }
.uppercase(Locale.getDefault())
val authorImageUrl = authors[0].imageUrl
val authorImageModifier = Modifier
.clip(CircleShape)
.size(24.dp)
Row(verticalAlignment = Alignment.CenterVertically) {
if (authorImageUrl.isNotEmpty()) {
AsyncImage(
modifier = authorImageModifier,
contentScale = ContentScale.Crop,
model = authorImageUrl,
contentDescription = null // decorative image
)
} else {
Icon(
modifier = authorImageModifier
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp),
imageVector = NiaIcons.Person,
contentDescription = null // decorative image
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(authorNameFormatted, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
fun NewsResourceTitle(
newsResourceTitle: String,

@ -136,7 +136,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td>
<td><code>When ForYouViewModel</code> receives the news resources it updates the feed state to <code>Success</code>. <code>ForYouScreen</code> then uses the news resources in the state to render the screen.
<p>
The screen shows the newly retrieved news resources (as long as the user has chosen at least one topic or author).
The screen shows the newly retrieved news resources (as long as the user has chosen at least one topic).
</td>
<td>Search for instances of <code>NewsFeedUiState.Success</code>
</td>
@ -165,11 +165,11 @@ Data is exposed as data streams. This means each client of the repository must b
Reads are performed from local storage as the source of truth, therefore errors are not expected when reading from `Repository` instances. However, errors may occur when trying to reconcile data in local storage with remote sources. For more on error reconciliation, check the data synchronization section below.
_Example: Read a list of authors_
_Example: Read a list of topics_
A list of Authors can be obtained by subscribing to `AuthorsRepository::getAuthors` flow which emits `List<Authors>`.
A list of Topics can be obtained by subscribing to `TopicsRepository::getTopics` flow which emits `List<Topic>`.
Whenever the list of authors changes (for example, when a new author is added), the updated `List<Author>` is emitted into the stream.
Whenever the list of topics changes (for example, when a new topic is added), the updated `List<Topic>` is emitted into the stream.
### Writing data
@ -274,20 +274,18 @@ The `feedState` is passed to the `ForYouScreen` composable, which handles both o
View models receive streams of data as cold [flows](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/index.html) from one or more repositories. These are [combined](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/combine.html) together to produce a single flow of UI state. This single flow is then converted to a hot flow using [stateIn](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/state-in.html). The conversion to a state flow enables UI elements to read the last known state from the flow.
**Example: Displaying followed topics and authors**
**Example: Displaying followed topics**
The `InterestsViewModel` exposes `uiState` as a `StateFlow<InterestsUiState>`. This hot flow is created by combining four data streams:
The `InterestsViewModel` exposes `uiState` as a `StateFlow<InterestsUiState>`. This hot flow is created by combining two data streams:
* List of authors (`getAuthors`)
* List of author IDs which the current user is following
* List of topics
* List of topic IDs which the current user is following
The list of `Author`s is mapped to a new list of `FollowableAuthor`s. `FollowableAuthor` is a wrapper for `Author` which also indicates whether the current user is following that author. The same transformation is applied for the list of `Topic`s.
The list of `Topic`s is mapped to a new list of `FollowableTopic`s. `FollowableTopic` is a wrapper for `Topic` which also indicates whether the current user is following that topic.
The two new lists are used to create a `InterestsUiState.Interests` state which is exposed to the UI.
The new list is used to create a `InterestsUiState.Interests` state which is exposed to the UI.
### Processing user interactions

@ -143,12 +143,12 @@ Using the above modularization strategy, the Now in Android app has the followin
<td>Functionality associated with a specific feature or user journey. Typically contains UI components and ViewModels which read data from other modules.<br>
Examples include:<br>
<ul>
<li><a href="https://github.com/android/nowinandroid/tree/main/feature/author"><code>feature:author</code></a> displays information about an author on the AuthorScreen.</li>
<li><a href="https://github.com/android/nowinandroid/tree/main/feature/topic"><code>feature:topic</code></a> displays information about a topic on the TopicScreen.</li>
<li><a href="https://github.com/android/nowinandroid/tree/main/feature/foryou"><code>feature:foryou</code></a> which displays the user's news feed, and onboarding during first run, on the For You screen.</li>
</ul>
</td>
<td><code>AuthorScreen</code><br>
<code>AuthorViewModel</code>
<td><code>TopicScreen</code><br>
<code>TopicViewModel</code>
</td>
</tr>
<tr>
@ -157,7 +157,6 @@ Using the above modularization strategy, the Now in Android app has the followin
<td>Fetching app data from multiple sources, shared by different features.
</td>
<td><code>TopicsRepository</code><br>
<code>AuthorsRepository</code>
</td>
</tr>
<tr>
@ -219,7 +218,7 @@ Using the above modularization strategy, the Now in Android app has the followin
</td>
<td>Model classes used throughout the app.
</td>
<td><code>Author</code><br>
<td><code>Topic</code><br>
<code>Episode</code><br>
<code>NewsResource</code>
</td>

@ -1 +0,0 @@
/build

@ -1,3 +0,0 @@
# :feature:author module
![Dependency graph](../../docs/images/graphs/dep_graph_feature_author.png)

@ -1,28 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
}
android {
namespace = "com.google.samples.apps.nowinandroid.feature.author"
}
dependencies {
implementation(libs.kotlinx.datetime)
}

@ -1,210 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.author
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* UI test for checking the correct behaviour of the Author screen;
* Verifies that, when a specific UiState is set, the corresponding
* composables and details are shown
*/
class AuthorScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var authorLoading: String
@Before
fun setup() {
composeTestRule.activity.apply {
authorLoading = getString(R.string.author_loading)
}
}
@Test
fun niaLoadingWheel_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
composeTestRule
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
@Test
fun authorTitle_whenAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Success(testAuthor),
newsUiState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
// Name is shown
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
// Bio is shown
composeTestRule
.onNodeWithText(testAuthor.author.bio)
.assertExists()
}
@Test
fun news_whenAuthorIsLoading_isNotShown() {
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
// Loading indicator shown
composeTestRule
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
@Test
fun news_whenSuccessAndAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorUiState = AuthorUiState.Success(testAuthor),
newsUiState = NewsUiState.Success(
sampleNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = { },
onFollowClick = { },
onBookmarkChanged = { _, _ -> },
)
}
// First news title shown
composeTestRule
.onNodeWithText(sampleNewsResources.first().title)
.assertExists()
}
}
private const val AUTHOR_1_NAME = "Author 1"
private const val AUTHOR_2_NAME = "Author 2"
private const val AUTHOR_3_NAME = "Author 3"
private const val AUTHOR_BIO = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
private val testAuthors = listOf(
FollowableAuthor(
Author(
id = "0",
name = AUTHOR_1_NAME,
twitter = "",
bio = AUTHOR_BIO,
mediumPage = "",
imageUrl = ""
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "1",
name = AUTHOR_2_NAME,
twitter = "",
bio = AUTHOR_BIO,
mediumPage = "",
imageUrl = ""
),
isFollowed = false
),
FollowableAuthor(
Author(
id = "2",
name = AUTHOR_3_NAME,
twitter = "",
bio = AUTHOR_BIO,
mediumPage = "",
imageUrl = ""
),
isFollowed = false
)
)
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,
authors = listOf(
Author(
id = "0",
name = "Headlines",
twitter = "",
bio = AUTHOR_BIO,
mediumPage = "",
imageUrl = ""
)
),
topics = emptyList()
)
)

@ -1,19 +0,0 @@
<?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 xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -1,273 +0,0 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.author
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
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.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.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Author
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
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
internal fun AuthorRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AuthorViewModel = hiltViewModel(),
) {
val authorUiState: AuthorUiState by viewModel.authorUiState.collectAsStateWithLifecycle()
val newsUiState: NewsUiState by viewModel.newsUiState.collectAsStateWithLifecycle()
AuthorScreen(
authorUiState = authorUiState,
newsUiState = newsUiState,
modifier = modifier,
onBackClick = onBackClick,
onFollowClick = viewModel::followAuthorToggle,
onBookmarkChanged = viewModel::bookmarkNews,
)
}
@VisibleForTesting
@Composable
internal fun AuthorScreen(
authorUiState: AuthorUiState,
newsUiState: NewsUiState,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
onBookmarkChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val scrollableState = rememberLazyListState()
TrackScrollJank(scrollableState = scrollableState, stateName = "author:column")
LazyColumn(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
state = scrollableState
) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (authorUiState) {
AuthorUiState.Loading -> {
item {
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = R.string.author_loading),
)
}
}
AuthorUiState.Error -> {
TODO()
}
is AuthorUiState.Success -> {
item {
AuthorToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
uiState = authorUiState.followableAuthor,
)
}
authorBody(
author = authorUiState.followableAuthor.author,
news = newsUiState,
onBookmarkChanged = onBookmarkChanged,
)
}
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
private fun LazyListScope.authorBody(
author: Author,
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit
) {
item {
AuthorHeader(author)
}
authorCards(news, onBookmarkChanged)
}
@Composable
private fun AuthorHeader(author: Author) {
Column(
modifier = Modifier.padding(horizontal = 24.dp)
) {
AsyncImage(
modifier = Modifier
.padding(bottom = 12.dp)
.size(216.dp)
.align(Alignment.CenterHorizontally)
.clip(CircleShape),
contentScale = ContentScale.Crop,
model = author.imageUrl,
contentDescription = "Author profile picture",
)
Text(author.name, style = MaterialTheme.typography.displayMedium)
if (author.bio.isNotEmpty()) {
Text(
text = author.bio,
modifier = Modifier.padding(top = 24.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
private fun LazyListScope.authorCards(
news: NewsUiState,
onBookmarkChanged: (String, Boolean) -> Unit
) {
when (news) {
is NewsUiState.Success -> {
newsResourceCardItems(
items = news.news,
newsResourceMapper = { it.newsResource },
isBookmarkedMapper = { it.isSaved },
onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) },
itemModifier = Modifier.padding(24.dp)
)
}
is NewsUiState.Loading -> item {
NiaLoadingWheel(contentDesc = "Loading news") // TODO
}
else -> item {
Text("Error") // TODO
}
}
}
@Composable
private fun AuthorToolbar(
uiState: FollowableAuthor,
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onFollowClick: (Boolean) -> Unit = {},
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(bottom = 32.dp)
) {
IconButton(onClick = { onBackClick() }) {
Icon(
imageVector = NiaIcons.ArrowBack,
contentDescription = stringResource(
id = com.google.samples.apps.nowinandroid.core.ui.R.string.back
)
)
}
val selected = uiState.isFollowed
NiaFilterChip(
modifier = Modifier.padding(horizontal = 16.dp),
selected = selected,
onSelectedChange = onFollowClick,
) {
if (selected) {
Text(stringResource(id = R.string.author_following))
} else {
Text(stringResource(id = R.string.author_not_following))
}
}
}
}
@DevicePreviews
@Composable
fun AuthorScreenPopulated() {
NiaTheme {
NiaBackground {
AuthorScreen(
authorUiState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)),
newsUiState = NewsUiState.Success(
previewNewsResources.mapIndexed { index, newsResource ->
SaveableNewsResource(
newsResource = newsResource,
isSaved = index % 2 == 0,
)
}
),
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
)
}
}
}
@DevicePreviews
@Composable
fun AuthorScreenLoading() {
NiaTheme {
NiaBackground {
AuthorScreen(
authorUiState = AuthorUiState.Loading,
newsUiState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
)
}
}
}

@ -1,154 +0,0 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.author
import androidx.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.UserDataRepository
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
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.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorArgs
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.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AuthorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
getSaveableNewsResources: GetSaveableNewsResourcesUseCase
) : ViewModel() {
private val authorArgs: AuthorArgs = AuthorArgs(savedStateHandle, stringDecoder)
val authorUiState: StateFlow<AuthorUiState> = authorUiState(
authorId = authorArgs.authorId,
userDataRepository = userDataRepository,
authorsRepository = authorsRepository
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AuthorUiState.Loading
)
val newsUiState: StateFlow<NewsUiState> =
getSaveableNewsResources.newsUiState(authorId = authorArgs.authorId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = NewsUiState.Loading
)
fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedAuthorId(authorArgs.authorId, followed)
}
}
fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked)
}
}
}
private fun authorUiState(
authorId: String,
userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
): Flow<AuthorUiState> {
// Observe the followed authors, as they could change over time.
val followedAuthorIds: Flow<Set<String>> =
userDataRepository.userData
.map { it.followedAuthors }
// Observe author information
val author: Flow<Author> = authorsRepository.getAuthor(
id = authorId
)
return combine(
followedAuthorIds,
author,
::Pair
)
.asResult()
.map { followedAuthorToAuthorResult ->
when (followedAuthorToAuthorResult) {
is Result.Success -> {
val (followedAuthors, author) = followedAuthorToAuthorResult.data
val followed = followedAuthors.contains(authorId)
AuthorUiState.Success(
followableAuthor = FollowableAuthor(
author = author,
isFollowed = followed
)
)
}
is Result.Loading -> {
AuthorUiState.Loading
}
is Result.Error -> {
AuthorUiState.Error
}
}
}
}
private fun GetSaveableNewsResourcesUseCase.newsUiState(
authorId: String
): Flow<NewsUiState> {
// Observe news
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
}
}
}
sealed interface AuthorUiState {
data class Success(val followableAuthor: FollowableAuthor) : AuthorUiState
object Error : AuthorUiState
object Loading : AuthorUiState
}
sealed interface NewsUiState {
data class Success(val news: List<SaveableNewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}

@ -1,54 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.author.navigation
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
import com.google.samples.apps.nowinandroid.feature.author.AuthorRoute
@VisibleForTesting
internal const val authorIdArg = "authorId"
internal class AuthorArgs(val authorId: String) {
constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) :
this(stringDecoder.decodeString(checkNotNull(savedStateHandle[authorIdArg])))
}
fun NavController.navigateToAuthor(authorId: String) {
val encodedString = Uri.encode(authorId)
this.navigate("author_route/$encodedString")
}
fun NavGraphBuilder.authorScreen(
onBackClick: () -> Unit
) {
composable(
route = "author_route/{$authorIdArg}",
arguments = listOf(
navArgument(authorIdArg) { type = NavType.StringType }
)
) {
AuthorRoute(onBackClick = onBackClick)
}
}

@ -1,22 +0,0 @@
<?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.
-->
<resources>
<string name="author">Author</string>
<string name="author_loading">Loading author</string>
<string name="author_following">FOLLOWING</string>
<string name="author_not_following">NOT FOLLOWING</string>
</resources>

@ -1,315 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.author
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.testing.decoder.FakeStringDecoder
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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.author.navigation.authorIdArg
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class AuthorViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: AuthorViewModel
@Before
fun setup() {
viewModel = AuthorViewModel(
savedStateHandle = SavedStateHandle(
mapOf(
authorIdArg to testInputAuthors[0].author.id
)
),
stringDecoder = FakeStringDecoder(),
userDataRepository = userDataRepository,
authorsRepository = authorsRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase
)
}
@Test
fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() }
// To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = viewModel.authorUiState.value
assertIs<AuthorUiState.Success>(item)
val authorFromRepository = authorsRepository.getAuthor(
id = testInputAuthors[0].author.id
).first()
item.followableAuthor.author
assertEquals(authorFromRepository, item.followableAuthor.author)
collectJob.cancel()
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
assertEquals(NewsUiState.Loading, viewModel.newsUiState.value)
}
@Test
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value)
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, viewModel.authorUiState.value)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() =
runTest {
val collectJob = launch(UnconfinedTestDispatcher()) {
combine(
viewModel.authorUiState,
viewModel.newsUiState,
::Pair
).collect()
}
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val authorState = viewModel.authorUiState.value
val newsUiState = viewModel.newsUiState.value
assertIs<AuthorUiState.Success>(authorState)
assertIs<NewsUiState.Loading>(newsUiState)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
val collectJob = launch(UnconfinedTestDispatcher()) {
combine(
viewModel.authorUiState,
viewModel.newsUiState,
::Pair
).collect()
}
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources)
val authorState = viewModel.authorUiState.value
val newsUiState = viewModel.newsUiState.value
assertIs<AuthorUiState.Success>(authorState)
assertIs<NewsUiState.Success>(newsUiState)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.authorUiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
// Set which author IDs are followed, not including 0.
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
viewModel.followAuthorToggle(true)
assertEquals(
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
viewModel.authorUiState.value
)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenNewsBookmarked_thenShowBookmarkedNews() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.newsUiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
newsRepository.sendNewsResources(sampleNewsResources)
// Set initial bookmarked status to false
userDataRepository.updateNewsResourceBookmark(
newsResourceId = sampleNewsResources.first().id,
bookmarked = false
)
viewModel.bookmarkNews(
newsResourceId = sampleNewsResources.first().id,
bookmarked = true
)
assertTrue(
(viewModel.newsUiState.value as NewsUiState.Success)
.news
.first { it.newsResource.id == sampleNewsResources.first().id }
.isSaved
)
collectJob.cancel()
}
}
private const val AUTHOR_1_NAME = "Author 1"
private const val AUTHOR_2_NAME = "Author 2"
private const val AUTHOR_3_NAME = "Author 3"
private const val AUTHOR_BIO = "At vero eos et accusamus."
private const val AUTHOR_TWITTER = "dev"
private const val AUTHOR_MEDIUM_PAGE = "URL"
private const val AUTHOR_IMAGE_URL = "Image URL"
private val testInputAuthors = listOf(
FollowableAuthor(
Author(
id = "0",
name = AUTHOR_1_NAME,
bio = AUTHOR_BIO,
twitter = AUTHOR_TWITTER,
mediumPage = AUTHOR_MEDIUM_PAGE,
imageUrl = AUTHOR_IMAGE_URL,
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "1",
name = AUTHOR_2_NAME,
bio = AUTHOR_BIO,
twitter = AUTHOR_TWITTER,
mediumPage = AUTHOR_MEDIUM_PAGE,
imageUrl = AUTHOR_IMAGE_URL,
),
isFollowed = false
),
FollowableAuthor(
Author(
id = "2",
name = AUTHOR_3_NAME,
bio = AUTHOR_BIO,
twitter = AUTHOR_TWITTER,
mediumPage = AUTHOR_MEDIUM_PAGE,
imageUrl = AUTHOR_IMAGE_URL,
),
isFollowed = false
)
)
private val testOutputAuthors = listOf(
FollowableAuthor(
Author(
id = "0",
name = AUTHOR_1_NAME,
bio = AUTHOR_BIO,
twitter = AUTHOR_TWITTER,
mediumPage = AUTHOR_MEDIUM_PAGE,
imageUrl = AUTHOR_IMAGE_URL,
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "1",
name = AUTHOR_2_NAME,
bio = AUTHOR_BIO,
twitter = AUTHOR_TWITTER,
mediumPage = AUTHOR_MEDIUM_PAGE,
imageUrl = AUTHOR_IMAGE_URL,
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "2",
name = AUTHOR_3_NAME,
bio = AUTHOR_BIO,
twitter = AUTHOR_TWITTER,
mediumPage = AUTHOR_MEDIUM_PAGE,
imageUrl = AUTHOR_IMAGE_URL,
),
isFollowed = false
)
)
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,
authors = listOf(
Author(
id = "0",
name = "Android Dev",
bio = "Hello there!",
twitter = "dev",
mediumPage = "URL",
imageUrl = "image URL",
)
),
topics = emptyList()
)
)

@ -28,10 +28,8 @@ 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.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -57,7 +55,6 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -80,7 +77,6 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -95,7 +91,7 @@ class ForYouScreenTest {
}
@Test
fun topicSelector_whenNoTopicsSelected_showsAuthorAndTopicChipsAndDisabledDoneButton() {
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
@ -103,26 +99,17 @@ class ForYouScreenTest {
onboardingUiState =
OnboardingUiState.Shown(
topics = testTopics,
authors = testAuthors
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
@ -144,7 +131,7 @@ class ForYouScreenTest {
}
@Test
fun topicSelector_whenSomeTopicsSelected_showsAuthorAndTopicChipsAndEnabledDoneButton() {
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
@ -154,79 +141,18 @@ class ForYouScreenTest {
// Follow one topic
topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1)
},
authors = testAuthors
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertExists()
.assertHasClickAction()
}
// Scroll until the Done button is visible
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(doneButtonMatcher)
composeTestRule
.onNode(doneButtonMatcher)
.assertExists()
.assertIsEnabled()
.assertHasClickAction()
}
@Test
fun topicSelector_whenSomeAuthorsSelected_showsAuthorAndTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
BoxWithConstraints {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(
// Follow one topic
topics = testTopics,
authors = testAuthors.mapIndexed { index, testAuthor ->
testAuthor.copy(isFollowed = index == 1)
}
),
feedState = NewsFeedUiState.Success(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
}
}
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic ->
composeTestRule
.onNodeWithText(testTopic.topic.name)
@ -254,13 +180,9 @@ class ForYouScreenTest {
ForYouScreen(
isSyncing = false,
onboardingUiState =
OnboardingUiState.Shown(
topics = testTopics,
authors = testAuthors
),
OnboardingUiState.Shown(topics = testTopics),
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -283,7 +205,6 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -309,7 +230,6 @@ class ForYouScreenTest {
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -349,14 +269,6 @@ private val testTopic = Topic(
url = "",
imageUrl = ""
)
private val testAuthor = Author(
id = "",
name = "",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = ""
)
private val testTopics = listOf(
FollowableTopic(
topic = testTopic.copy(id = "0", name = "Headlines"),
@ -371,13 +283,3 @@ private val testTopics = listOf(
isFollowed = false
),
)
private val testAuthors = listOf(
FollowableAuthor(
author = testAuthor.copy(id = "0", name = "Android Dev"),
isFollowed = false
),
FollowableAuthor(
author = testAuthor.copy(id = "1", name = "Android Dev 2"),
isFollowed = false
),
)

@ -1,249 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.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.ui.TrackScrollJank
@Composable
fun AuthorsCarousel(
authors: List<FollowableAuthor>,
onAuthorClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val lazyListState = rememberLazyListState()
val tag = "forYou:authors"
TrackScrollJank(scrollableState = lazyListState, stateName = tag)
LazyRow(
modifier = modifier.testTag(tag),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
state = lazyListState
) {
items(items = authors, key = { item -> item.author.id }) { followableAuthor ->
AuthorItem(
author = followableAuthor.author,
following = followableAuthor.isFollowed,
onAuthorClick = { following ->
onAuthorClick(followableAuthor.author.id, following)
},
)
}
}
}
@Composable
fun AuthorItem(
author: Author,
following: Boolean,
onAuthorClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val followDescription = if (following) {
stringResource(R.string.following)
} else {
stringResource(R.string.not_following)
}
val followActionLabel = if (following) {
stringResource(R.string.unfollow)
} else {
stringResource(R.string.follow)
}
Column(
modifier = modifier
.toggleable(
value = following,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onValueChange = { newFollowing -> onAuthorClick(newFollowing) },
)
.padding(8.dp)
.sizeIn(maxWidth = 48.dp)
.semantics(mergeDescendants = true) {
// Add information for A11y services, explaining what each state means and
// what will happen when the user interacts with the author item.
stateDescription = "$followDescription ${author.name}"
onClick(label = followActionLabel, action = null)
}
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val authorImageModifier = Modifier
.size(48.dp)
.clip(CircleShape)
if (author.imageUrl.isEmpty()) {
Icon(
modifier = authorImageModifier
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp),
imageVector = NiaIcons.Person,
contentDescription = null // decorative image
)
} else {
AsyncImage(
modifier = authorImageModifier,
model = author.imageUrl,
contentScale = ContentScale.Crop,
contentDescription = null
)
}
val backgroundColor =
if (following)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
Icon(
imageVector = if (following) NiaIcons.Check else NiaIcons.Add,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(18.dp)
.drawBehind {
drawCircle(
color = backgroundColor,
radius = 12.dp.toPx()
)
}
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = author.name,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium,
maxLines = 2,
modifier = Modifier.fillMaxWidth()
)
}
}
@Preview
@Composable
fun AuthorCarouselPreview() {
NiaTheme {
Surface {
AuthorsCarousel(
authors = listOf(
FollowableAuthor(
Author(
id = "1",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "3",
name = "Android Dev3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
false
)
),
onAuthorClick = { _, _ -> },
)
}
}
}
@Preview
@Composable
fun AuthorItemPreview() {
NiaTheme {
Surface {
AuthorItem(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
following = true,
onAuthorClick = { }
)
}
}
}

@ -82,10 +82,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverl
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.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
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
@ -108,7 +106,6 @@ internal fun ForYouRoute(
onboardingUiState = onboardingUiState,
feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier
@ -121,7 +118,6 @@ internal fun ForYouScreen(
onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
@ -162,7 +158,6 @@ internal fun ForYouScreen(
) {
onboarding(
onboardingUiState = onboardingUiState,
onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics,
// Custom LayoutModifier to remove the enforced parent 16.dp contentPadding
@ -224,7 +219,6 @@ internal fun ForYouScreen(
*/
private fun LazyGridScope.onboarding(
onboardingUiState: OnboardingUiState,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
interestsItemModifier: Modifier = Modifier
@ -253,13 +247,6 @@ private fun LazyGridScope.onboarding(
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
AuthorsCarousel(
authors = onboardingUiState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
TopicSelection(
onboardingUiState,
onTopicCheckedChanged,
@ -414,7 +401,6 @@ fun ForYouScreenPopulatedFeed() {
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -436,7 +422,6 @@ fun ForYouScreenOfflinePopulatedFeed() {
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -453,7 +438,6 @@ fun ForYouScreenTopicSelection() {
isSyncing = false,
onboardingUiState = OnboardingUiState.Shown(
topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) }
),
feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map {
@ -461,7 +445,6 @@ fun ForYouScreenTopicSelection() {
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -479,7 +462,6 @@ fun ForYouScreenLoading() {
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)
@ -501,7 +483,6 @@ fun ForYouScreenPopulatedAndLoading() {
}
),
onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }
)

@ -22,7 +22,6 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel
@ -43,7 +42,6 @@ class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository,
private val getSaveableNewsResources: GetSaveableNewsResourcesUseCase,
getSortedFollowableAuthors: GetSortedFollowableAuthorsUseCase,
getFollowableTopics: GetFollowableTopicsUseCase
) : ViewModel() {
@ -64,14 +62,12 @@ class ForYouViewModel @Inject constructor(
// show an empty news list to clearly demonstrate that their selections affect the
// news articles they will see.
if (!userData.shouldHideOnboarding &&
userData.followedAuthors.isEmpty() &&
userData.followedTopics.isEmpty()
) {
flowOf(NewsFeedUiState.Success(emptyList()))
} else {
getSaveableNewsResources(
filterTopicIds = userData.followedTopics,
filterAuthorIds = userData.followedAuthors
filterTopicIds = userData.followedTopics
).mapToFeedState()
}
}
@ -88,14 +84,10 @@ class ForYouViewModel @Inject constructor(
val onboardingUiState: StateFlow<OnboardingUiState> =
combine(
shouldShowOnboarding,
getFollowableTopics(),
getSortedFollowableAuthors()
) { shouldShowOnboarding, topics, authors ->
getFollowableTopics()
) { shouldShowOnboarding, topics ->
if (shouldShowOnboarding) {
OnboardingUiState.Shown(
topics = topics,
authors = authors
)
OnboardingUiState.Shown(topics = topics)
} else {
OnboardingUiState.NotShown
}
@ -112,12 +104,6 @@ class ForYouViewModel @Inject constructor(
}
}
fun updateAuthorSelection(authorId: String, isChecked: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedAuthorId(authorId, isChecked)
}
}
fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) {
viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, isChecked)

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
/**
@ -39,16 +38,14 @@ sealed interface OnboardingUiState {
object NotShown : OnboardingUiState
/**
* There is a onboarding state, with the given lists of topics and authors.
* There is a onboarding state, with the given lists of topics.
*/
data class Shown(
val topics: List<FollowableTopic>,
val authors: List<FollowableAuthor>
val topics: List<FollowableTopic>
) : OnboardingUiState {
/**
* True if the onboarding can be dismissed.
*/
val isDismissable: Boolean get() =
topics.any { it.isFollowed } || authors.any { it.isFollowed }
val isDismissable: Boolean get() = topics.any { it.isFollowed }
}
}

@ -18,15 +18,11 @@ package com.google.samples.apps.nowinandroid.feature.foryou
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsUseCase
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.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.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@ -56,17 +52,12 @@ class ForYouViewModelTest {
private val networkMonitor = TestNetworkMonitor()
private val syncStatusMonitor = TestSyncStatusMonitor()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
newsRepository = newsRepository,
userDataRepository = userDataRepository
)
private val getSortedFollowableAuthors = GetSortedFollowableAuthorsUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
topicsRepository = topicsRepository,
userDataRepository = userDataRepository
@ -79,7 +70,6 @@ class ForYouViewModelTest {
syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase,
getSortedFollowableAuthors = getSortedFollowableAuthors,
getFollowableTopics = getFollowableTopicsUseCase
)
}
@ -126,24 +116,6 @@ class ForYouViewModelTest {
collectJob.cancel()
}
@Test
fun stateIsLoadingWhenFollowedAuthorsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors)
assertEquals(
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 =
@ -162,24 +134,6 @@ class ForYouViewModelTest {
collectJob2.cancel()
}
@Test
fun onboardingStateIsLoadingWhenAuthorsAreLoading() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals(
OnboardingUiState.Loading,
viewModel.onboardingUiState.value
)
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest {
val collectJob1 =
@ -188,8 +142,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals(
OnboardingUiState.Shown(
@ -228,41 +180,6 @@ class ForYouViewModelTest {
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
@ -278,15 +195,13 @@ class ForYouViewModelTest {
}
@Test
fun onboardingIsShownAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest {
fun onboardingIsShownAfterLoadingEmptyFollowedTopics() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedTopicIds(emptySet())
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
@ -326,41 +241,6 @@ class ForYouViewModelTest {
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
@ -382,8 +262,6 @@ class ForYouViewModelTest {
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("0", "1"))
viewModel.dismissOnboarding()
@ -425,8 +303,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
@ -466,41 +342,6 @@ class ForYouViewModelTest {
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
@ -550,232 +391,6 @@ class ForYouViewModelTest {
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = listOf(
SaveableNewsResource(
newsResource = sampleNewsResources[1],
isSaved = false
),
SaveableNewsResource(
newsResource = sampleNewsResources[2],
isSaved = false
)
)
),
viewModel.feedState.value
)
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun authorSelectionUpdatesAfterSelectingAuthor() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList(),
),
viewModel.feedState.value
)
viewModel.updateAuthorSelection("1", isChecked = true)
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = true
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
@ -807,8 +422,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateTopicSelection("1", isChecked = true)
viewModel.updateTopicSelection("1", isChecked = false)
@ -851,142 +464,6 @@ class ForYouViewModelTest {
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
assertEquals(
NewsFeedUiState.Success(
feed = emptyList()
),
viewModel.feedState.value
)
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun authorSelectionUpdatesAfterUnselectingAuthor() = runTest {
val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateAuthorSelection("1", isChecked = true)
viewModel.updateAuthorSelection("1", isChecked = false)
assertEquals(
OnboardingUiState.Shown(
topics = listOf(
FollowableTopic(
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
isFollowed = false
)
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
),
),
viewModel.onboardingUiState.value
)
@ -1009,8 +486,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("1"))
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setShouldHideOnboarding(true)
newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved("2", true)
@ -1040,33 +515,6 @@ class ForYouViewModelTest {
}
}
private val sampleAuthors = listOf(
Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
)
private val sampleTopics = listOf(
Topic(
id = "0",
@ -1116,16 +564,6 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL",
)
),
authors = listOf(
Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
)
),
NewsResource(
id = "2",
@ -1147,16 +585,6 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL",
),
),
authors = listOf(
Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
)
),
NewsResource(
id = "3",
@ -1176,15 +604,5 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL",
),
),
authors = listOf(
Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
)
),
)

@ -25,12 +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.Topic
import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen
import com.google.samples.apps.nowinandroid.feature.interests.InterestsTabState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.R
import org.junit.Before
@ -67,18 +64,7 @@ class InterestsScreenTest {
@Test
fun niaLoadingWheel_inTopics_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Loading, tabIndex = 0)
}
composeTestRule
.onNodeWithContentDescription(interestsLoading)
.assertExists()
}
@Test
fun niaLoadingWheel_inAuthors_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Loading, tabIndex = 1)
InterestsScreen(uiState = InterestsUiState.Loading)
}
composeTestRule
@ -90,8 +76,7 @@ class InterestsScreenTest {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = testTopics, authors = listOf()),
tabIndex = 0
uiState = InterestsUiState.Interests(topics = testTopics)
)
}
@ -112,55 +97,12 @@ class InterestsScreenTest {
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardFollowButton)
.assertCountEquals(numberOfUnfollowedTopics)
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardUnfollowButton)
.assertCountEquals(testAuthors.filter { it.isFollowed }.size)
}
@Test
fun interestsWithTopics_whenAuthorsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = listOf(), authors = testAuthors),
tabIndex = 1
)
}
composeTestRule
.onNodeWithText("Android Dev")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Android Dev 2")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Android Dev 3")
.assertIsDisplayed()
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardFollowButton)
.assertCountEquals(numberOfUnfollowedAuthors)
composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardUnfollowButton)
.assertCountEquals(testTopics.filter { it.isFollowed }.size)
}
@Test
fun topicsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Empty, tabIndex = 0)
}
composeTestRule
.onNodeWithText(interestsEmptyHeader)
.assertIsDisplayed()
}
@Test
fun authorsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Empty, tabIndex = 1)
InterestsScreen(uiState = InterestsUiState.Empty)
}
composeTestRule
@ -169,18 +111,11 @@ class InterestsScreenTest {
}
@Composable
private fun InterestsScreen(uiState: InterestsUiState, tabIndex: Int = 0) {
private fun InterestsScreen(uiState: InterestsUiState) {
InterestsScreen(
uiState = uiState,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = tabIndex
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {},
navigateToTopic = {}
)
}
}
@ -229,41 +164,4 @@ private val testTopics = listOf(
)
)
private val testAuthors = listOf(
FollowableAuthor(
Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
)
private val numberOfUnfollowedTopics = testTopics.filter { !it.isFollowed }.size
private val numberOfUnfollowedAuthors = testAuthors.filter { !it.isFollowed }.size

@ -29,57 +29,34 @@ import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow
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.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
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
internal fun InterestsRoute(
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val tabState by viewModel.tabState.collectAsStateWithLifecycle()
InterestsScreen(
uiState = uiState,
tabState = tabState,
followTopic = viewModel::followTopic,
followAuthor = viewModel::followAuthor,
navigateToAuthor = navigateToAuthor,
navigateToTopic = navigateToTopic,
switchTab = viewModel::switchTab,
modifier = modifier
)
TrackDisposableJank(tabState) { metricsHolder ->
metricsHolder.state?.putState("Interests:TabState", "currentIndex:${tabState.currentIndex}")
onDispose {
metricsHolder.state?.removeState("Interests:TabState")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InterestsScreen(
uiState: InterestsUiState,
tabState: InterestsTabState,
followAuthor: (String, Boolean) -> Unit,
followTopic: (String, Boolean) -> Unit,
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit,
switchTab: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -93,56 +70,13 @@ internal fun InterestsScreen(
contentDesc = stringResource(id = R.string.interests_loading),
)
is InterestsUiState.Interests ->
InterestsContent(
tabState = tabState,
switchTab = switchTab,
uiState = uiState,
navigateToTopic = navigateToTopic,
followTopic = followTopic,
navigateToAuthor = navigateToAuthor,
followAuthor = followAuthor
)
is InterestsUiState.Empty -> InterestsEmptyScreen()
}
}
}
@Composable
private fun InterestsContent(
tabState: InterestsTabState,
switchTab: (Int) -> Unit,
uiState: InterestsUiState.Interests,
navigateToTopic: (String) -> Unit,
followTopic: (String, Boolean) -> Unit,
navigateToAuthor: (String) -> Unit,
followAuthor: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier) {
NiaTabRow(selectedTabIndex = tabState.currentIndex) {
tabState.titles.forEachIndexed { index, titleId ->
NiaTab(
selected = index == tabState.currentIndex,
onClick = { switchTab(index) },
text = { Text(text = stringResource(id = titleId)) }
)
}
}
when (tabState.currentIndex) {
0 -> {
TopicsTabContent(
topics = uiState.topics,
onTopicClick = navigateToTopic,
onFollowButtonClick = followTopic,
modifier = modifier,
)
}
1 -> {
AuthorsTabContent(
authors = uiState.authors,
onAuthorClick = navigateToAuthor,
onFollowButtonClick = followAuthor,
)
}
is InterestsUiState.Empty -> InterestsEmptyScreen()
}
}
}
@ -159,18 +93,10 @@ fun InterestsScreenPopulated() {
NiaBackground {
InterestsScreen(
uiState = InterestsUiState.Interests(
authors = previewAuthors.map { FollowableAuthor(it, false) },
topics = previewTopics.map { FollowableTopic(it, false) }
),
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {}
)
}
}
@ -183,15 +109,8 @@ fun InterestsScreenLoading() {
NiaBackground {
InterestsScreen(
uiState = InterestsUiState.Loading,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {},
)
}
}
@ -204,15 +123,8 @@ fun InterestsScreenEmpty() {
NiaBackground {
InterestsScreen(
uiState = InterestsUiState.Empty,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {}
)
}
}

@ -20,9 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsUseCase
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
@ -30,16 +28,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class InterestsViewModel @Inject constructor(
val userDataRepository: UserDataRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
getSortedFollowableAuthors: GetSortedFollowableAuthorsUseCase
) : ViewModel() {
private val _tabState = MutableStateFlow(
@ -50,35 +46,20 @@ class InterestsViewModel @Inject constructor(
)
val tabState: StateFlow<InterestsTabState> = _tabState.asStateFlow()
val uiState: StateFlow<InterestsUiState> = combine(
getSortedFollowableAuthors(),
getFollowableTopics(sortBy = TopicSortField.NAME),
InterestsUiState::Interests
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading
)
val uiState: StateFlow<InterestsUiState> =
getFollowableTopics(sortBy = TopicSortField.NAME).map(
InterestsUiState::Interests
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading
)
fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedTopicId(followedTopicId, followed)
}
}
fun followAuthor(followedAuthorId: String, followed: Boolean) {
viewModelScope.launch {
userDataRepository.toggleFollowedAuthorId(followedAuthorId, followed)
}
}
fun switchTab(newIndex: Int) {
if (newIndex != tabState.value.currentIndex) {
_tabState.update {
it.copy(currentIndex = newIndex)
}
}
}
}
data class InterestsTabState(
@ -90,7 +71,6 @@ sealed interface InterestsUiState {
object Loading : InterestsUiState
data class Interests(
val authors: List<FollowableAuthor>,
val topics: List<FollowableTopic>
) : InterestsUiState

@ -23,13 +23,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
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.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
@Composable
@ -64,35 +61,3 @@ fun TopicsTabContent(
}
}
}
@Composable
fun AuthorsTabContent(
authors: List<FollowableAuthor>,
onAuthorClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier
.padding(horizontal = 16.dp)
.testTag("interests:people"),
contentPadding = PaddingValues(top = 8.dp)
) {
authors.forEach { followableAuthor ->
item {
InterestsItem(
name = followableAuthor.author.name,
following = followableAuthor.isFollowed,
topicImageUrl = followableAuthor.author.imageUrl,
onClick = { onAuthorClick(followableAuthor.author.id) },
onFollowButtonClick = { onFollowButtonClick(followableAuthor.author.id, it) },
iconModifier = Modifier.clip(CircleShape)
)
}
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}

@ -32,7 +32,6 @@ fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) {
fun NavGraphBuilder.interestsGraph(
navigateToTopic: (String) -> Unit,
navigateToAuthor: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit
) {
navigation(
@ -42,7 +41,6 @@ fun NavGraphBuilder.interestsGraph(
composable(route = interestsRoute) {
InterestsRoute(
navigateToTopic = navigateToTopic,
navigateToAuthor = navigateToAuthor,
)
}
nestedGraphs()

@ -17,12 +17,8 @@
package com.google.samples.apps.nowinandroid.interests
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSortedFollowableAuthorsUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
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
@ -47,17 +43,11 @@ class InterestsViewModelTest {
val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository()
private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
topicsRepository = topicsRepository,
userDataRepository = userDataRepository
)
private val getSortedFollowableAuthors =
GetSortedFollowableAuthorsUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: InterestsViewModel
@Before
@ -65,7 +55,6 @@ class InterestsViewModelTest {
viewModel = InterestsViewModel(
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
getSortedFollowableAuthors = getSortedFollowableAuthors
)
}
@ -78,31 +67,17 @@ class InterestsViewModelTest {
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
}
@Test
fun uiState_whenFollowedAuthorsAreLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(emptySet())
userDataRepository.setFollowedTopicIds(setOf("1"))
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
}
@Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
@ -118,29 +93,7 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()),
viewModel.uiState.value
)
collectJob.cancel()
}
@Test
fun uiState_whenFollowingNewAuthor_thenShowUpdatedAuthors() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[0].author.id))
topicsRepository.sendTopics(listOf())
userDataRepository.setFollowedTopicIds(setOf())
viewModel.followAuthor(
followedAuthorId = testInputAuthors[1].author.id,
followed = true
)
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors),
InterestsUiState.Interests(topics = testOutputTopics),
viewModel.uiState.value
)
@ -153,8 +106,6 @@ class InterestsViewModelTest {
val toggleTopicId = testOutputTopics[1].topic.id
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testOutputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
@ -172,31 +123,7 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()),
viewModel.uiState.value
)
collectJob.cancel()
}
@Test
fun uiState_whenUnfollowingAuthors_thenShowUpdatedAuthors() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testOutputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(
setOf(testOutputAuthors[0].author.id, testOutputAuthors[1].author.id)
)
topicsRepository.sendTopics(listOf())
userDataRepository.setFollowedTopicIds(setOf())
viewModel.followAuthor(
followedAuthorId = testOutputAuthors[1].author.id,
followed = false
)
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors),
InterestsUiState.Interests(topics = testInputTopics),
viewModel.uiState.value
)
@ -212,78 +139,6 @@ private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dign
private const val TOPIC_URL = "URL"
private const val TOPIC_IMAGE_URL = "Image URL"
private val testInputAuthors = listOf(
FollowableAuthor(
Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
)
private val testOutputAuthors = listOf(
FollowableAuthor(
Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = true
),
FollowableAuthor(
Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
)
private val testInputTopics = listOf(
FollowableTopic(
Topic(

@ -209,7 +209,6 @@ private val sampleNewsResources = listOf(
url = "",
imageUrl = ""
)
),
authors = emptyList()
)
)
)

@ -136,7 +136,6 @@ private fun newsUiState(
): Flow<NewsUiState> {
// Observe news
val news: Flow<List<SaveableNewsResource>> = getSaveableNewsResources(
filterAuthorIds = emptySet(),
filterTopicIds = setOf(element = topicId),
)

@ -267,6 +267,5 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL",
)
),
authors = emptyList()
)
)

@ -46,7 +46,6 @@ include(":core:model")
include(":core:network")
include(":core:ui")
include(":core:testing")
include(":feature:author")
include(":feature:foryou")
include(":feature:interests")
include(":feature:bookmarks")

@ -25,7 +25,6 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkerParameters
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
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.datastore.ChangeListVersions
@ -52,7 +51,6 @@ class SyncWorker @AssistedInject constructor(
private val niaPreferences: NiaPreferencesDataSource,
private val topicRepository: TopicsRepository,
private val newsRepository: NewsRepository,
private val authorsRepository: AuthorsRepository,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : CoroutineWorker(appContext, workerParams), Synchronizer {
@ -64,7 +62,6 @@ class SyncWorker @AssistedInject constructor(
// First sync the repositories in parallel
val syncedSuccessfully = awaitAll(
async { topicRepository.sync() },
async { authorsRepository.sync() },
async { newsRepository.sync() },
).all { it }

Loading…
Cancel
Save