Merge pull request #473 from android/dec5-merge

Remove authors from app (Dec 5th 2022 Merge)
pull/482/head
Don Turner 2 years ago committed by GitHub
commit 7dec90ba30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -28,9 +28,6 @@
<option name="JD_PRESERVE_LINE_FEEDS" value="true" /> <option name="JD_PRESERVE_LINE_FEEDS" value="true" />
</JavaCodeStyleSettings> </JavaCodeStyleSettings>
<JetCodeStyleSettings> <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" value="99" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="99" /> <option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="99" />
<option name="IMPORT_NESTED_CLASSES" value="true" /> <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** displays content from the
[Now in Android](https://developer.android.com/series/now-in-android) series. Users can browse for [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 links to recent videos, articles and other content. Users can also follow topics they are interested
in or follow specific authors. in.
## Screenshots ## Screenshots

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

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

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

@ -31,43 +31,6 @@ fun MacrobenchmarkScope.forYouWaitForContent() {
obj.wait(untilHasChildren(), 30_000) 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() { fun MacrobenchmarkScope.forYouScrollFeedDownUp() {
val feedList = device.findObject(By.res("forYou:feed")) val feedList = device.findObject(By.res("forYou:feed"))
device.flingElementDownUp(feedList) device.flingElementDownUp(feedList)

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

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

@ -16,9 +16,7 @@
package com.google.samples.apps.nowinandroid.core.data.di 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.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.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
@ -40,11 +38,6 @@ interface DataModule {
topicsRepository: OfflineFirstTopicsRepository topicsRepository: OfflineFirstTopicsRepository
): TopicsRepository ): TopicsRepository
@Binds
fun bindsAuthorsRepository(
authorsRepository: OfflineFirstAuthorsRepository
): AuthorsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository 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 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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -44,22 +42,6 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
type = type, 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 shell [TopicEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB * a [NewsResourceEntity] into the DB
@ -83,11 +65,3 @@ fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef>
topicId = topicId 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>> 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( fun getNewsResources(
filterAuthorIds: Set<String> = emptySet(),
filterTopicIds: Set<String> = emptySet(), filterTopicIds: Set<String> = emptySet(),
): Flow<List<NewsResource>> ): 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.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.changeListSync 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.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.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells 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.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao 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.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -44,7 +40,6 @@ import kotlinx.coroutines.flow.map
*/ */
class OfflineFirstNewsRepository @Inject constructor( class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
private val authorDao: AuthorDao,
private val topicDao: TopicDao, private val topicDao: TopicDao,
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
) : NewsRepository { ) : NewsRepository {
@ -54,10 +49,8 @@ class OfflineFirstNewsRepository @Inject constructor(
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources( override fun getNewsResources(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String> filterTopicIds: Set<String>
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources( ): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
filterAuthorIds = filterAuthorIds,
filterTopicIds = filterTopicIds filterTopicIds = filterTopicIds
) )
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
@ -83,12 +76,6 @@ class OfflineFirstNewsRepository @Inject constructor(
.flatten() .flatten()
.distinctBy(TopicEntity::id) .distinctBy(TopicEntity::id)
) )
authorDao.insertOrIgnoreAuthors(
authorEntities = networkNewsResources
.map(NetworkNewsResource::authorEntityShells)
.flatten()
.distinctBy(AuthorEntity::id)
)
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
@ -99,12 +86,6 @@ class OfflineFirstNewsRepository @Inject constructor(
.distinct() .distinct()
.flatten() .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) = override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed) 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) = override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) =
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)

@ -38,16 +38,6 @@ interface UserDataRepository {
*/ */
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) 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 * 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) }.flowOn(ioDispatcher)
override fun getNewsResources( override fun getNewsResources(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>, filterTopicIds: Set<String>,
): Flow<List<NewsResource>> = ): Flow<List<NewsResource>> =
flow { flow {
emit( emit(
datasource datasource
.getNewsResources() .getNewsResources()
.filter { .filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
it.authors.intersect(filterAuthorIds).isNotEmpty() ||
it.topics.intersect(filterTopicIds).isNotEmpty()
}
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)

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

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.data.model 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.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.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -27,23 +26,6 @@ import org.junit.Test
class NetworkEntityKtTest { 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 @Test
fun network_topic_can_be_mapped_to_topic_entity() { fun network_topic_can_be_mapped_to_topic_entity() {
val networkModel = NetworkTopic( 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.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity 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.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells 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.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.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource 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.TestTopicDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds 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.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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource 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.TopicEntity
@ -53,8 +49,6 @@ class OfflineFirstNewsRepositoryTest {
private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var newsResourceDao: TestNewsResourceDao
private lateinit var authorDao: TestAuthorDao
private lateinit var topicDao: TestTopicDao private lateinit var topicDao: TestTopicDao
private lateinit var network: TestNiaNetworkDataSource private lateinit var network: TestNiaNetworkDataSource
@ -67,7 +61,6 @@ class OfflineFirstNewsRepositoryTest {
@Before @Before
fun setup() { fun setup() {
newsResourceDao = TestNewsResourceDao() newsResourceDao = TestNewsResourceDao()
authorDao = TestAuthorDao()
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
synchronizer = TestSynchronizer( synchronizer = TestSynchronizer(
@ -78,7 +71,6 @@ class OfflineFirstNewsRepositoryTest {
subject = OfflineFirstNewsRepository( subject = OfflineFirstNewsRepository(
newsResourceDao = newsResourceDao, newsResourceDao = newsResourceDao,
authorDao = authorDao,
topicDao = topicDao, topicDao = topicDao,
network = network, 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 @Test
fun offlineFirstNewsRepository_sync_pulls_from_network() = fun offlineFirstNewsRepository_sync_pulls_from_network() =
runTest { 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 @Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
runTest { runTest {
@ -292,18 +245,4 @@ class OfflineFirstNewsRepositoryTest {
newsResourceDao.topicCrossReferences 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( UserData(
bookmarkedNewsResources = emptySet(), bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(), followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false 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 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.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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource 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 topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
internal var authorCrossReferences: List<NewsResourceAuthorCrossRef> = listOf()
override fun getNewsResources(): Flow<List<PopulatedNewsResource>> = override fun getNewsResources(): Flow<List<PopulatedNewsResource>> =
entitiesStateFlow.map { entitiesStateFlow.map {
it.map(NewsResourceEntity::asPopulatedNewsResource) it.map(NewsResourceEntity::asPopulatedNewsResource)
} }
override fun getNewsResources( override fun getNewsResources(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String> filterTopicIds: Set<String>
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> =
getNewsResources() getNewsResources()
.map { resources -> .map { resources ->
resources.filter { resource -> resources.filter { resource ->
resource.topics.any { it.id in filterTopicIds } || resource.topics.any { it.id in filterTopicIds }
resource.authors.any { it.id in filterAuthorIds }
} }
} }
@ -95,12 +89,6 @@ class TestNewsResourceDao : NewsResourceDao {
topicCrossReferences = newsResourceTopicCrossReferences topicCrossReferences = newsResourceTopicCrossReferences
} }
override suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
) {
authorCrossReferences = newsResourceAuthorCrossReferences
}
override suspend fun deleteNewsResources(ids: List<String>) { override suspend fun deleteNewsResources(ids: List<String>) {
val idSet = ids.toSet() val idSet = ids.toSet()
entitiesStateFlow.update { entities -> entitiesStateFlow.update { entities ->
@ -111,16 +99,6 @@ class TestNewsResourceDao : NewsResourceDao {
private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource( private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource(
entity = this, entity = this,
authors = listOf(
AuthorEntity(
id = "id",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf( topics = listOf(
TopicEntity( TopicEntity(
id = filteredInterestsIds.random(), 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.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource 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.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -28,7 +27,6 @@ import kotlinx.serialization.json.Json
enum class CollectionType { enum class CollectionType {
Topics, Topics,
Authors,
NewsResources NewsResources
} }
@ -37,19 +35,18 @@ enum class CollectionType {
*/ */
class TestNiaNetworkDataSource : NiaNetworkDataSource { 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 allTopics = runBlocking { source.getTopics() }
private val allAuthors = runBlocking { source.getAuthors() }
private val allNewsResources = runBlocking { source.getNewsResources() } private val allNewsResources = runBlocking { source.getNewsResources() }
private val changeLists: MutableMap<CollectionType, List<NetworkChangeList>> = mutableMapOf( private val changeLists: MutableMap<CollectionType, List<NetworkChangeList>> = mutableMapOf(
CollectionType.Topics to allTopics CollectionType.Topics to allTopics
.mapToChangeList(idGetter = NetworkTopic::id), .mapToChangeList(idGetter = NetworkTopic::id),
CollectionType.Authors to allAuthors
.mapToChangeList(idGetter = NetworkAuthor::id),
CollectionType.NewsResources to allNewsResources CollectionType.NewsResources to allNewsResources
.mapToChangeList(idGetter = NetworkNewsResource::id), .mapToChangeList(idGetter = NetworkNewsResource::id),
) )
@ -60,12 +57,6 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
idGetter = NetworkTopic::id 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> = override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds( allNewsResources.matchIds(
ids = ids, ids = ids,
@ -75,9 +66,6 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.Topics).after(after) 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> = override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.NewsResources).after(after) changeLists.getValue(CollectionType.NewsResources).after(after)

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.database.model 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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -37,16 +36,6 @@ class PopulatedNewsResourceKtTest {
type = Video, type = Video,
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
), ),
authors = listOf(
AuthorEntity(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf( topics = listOf(
TopicEntity( TopicEntity(
id = "3", id = "3",
@ -69,16 +58,6 @@ class PopulatedNewsResourceKtTest {
headerImageUrl = "headerImageUrl", headerImageUrl = "headerImageUrl",
type = Video, type = Video,
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
authors = listOf(
Author(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf( topics = listOf(
Topic( Topic(
id = "3", 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.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase 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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -38,7 +36,6 @@ class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao private lateinit var newsResourceDao: NewsResourceDao
private lateinit var topicDao: TopicDao private lateinit var topicDao: TopicDao
private lateinit var authorDao: AuthorDao
private lateinit var db: NiaDatabase private lateinit var db: NiaDatabase
@Before @Before
@ -50,7 +47,6 @@ class NewsResourceDaoTest {
).build() ).build()
newsResourceDao = db.newsResourceDao() newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao() topicDao = db.topicDao()
authorDao = db.authorDao()
} }
@Test @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 @Test
fun newsResourceDao_deletes_items_by_ids() = fun newsResourceDao_deletes_items_by_ids() =
runTest { 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( private fun testTopicEntity(
id: String = "0", id: String = "0",
name: String name: String

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

@ -50,4 +50,14 @@ object DatabaseMigrations {
) )
) )
class Schema10to11 : AutoMigrationSpec 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.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters 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.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao 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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity 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( @Database(
entities = [ entities = [
AuthorEntity::class,
NewsResourceAuthorCrossRef::class,
NewsResourceEntity::class, NewsResourceEntity::class,
NewsResourceTopicCrossRef::class, NewsResourceTopicCrossRef::class,
TopicEntity::class, TopicEntity::class,
], ],
version = 11, version = 12,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), 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 = 7, to = 8),
AutoMigration(from = 8, to = 9), AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10), 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, exportSchema = true,
) )
@ -60,6 +56,5 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
) )
abstract class NiaDatabase : RoomDatabase() { abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao abstract fun topicDao(): TopicDao
abstract fun authorDao(): AuthorDao
abstract fun newsResourceDao(): NewsResourceDao 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.Transaction
import androidx.room.Update import androidx.room.Update
import androidx.room.Upsert 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.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
@ -53,16 +52,10 @@ interface NewsResourceDao {
SELECT news_resource_id FROM news_resources_topics SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds) 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 ORDER BY publish_date DESC
""" """
) )
fun getNewsResources( fun getNewsResources(
filterAuthorIds: Set<String> = emptySet(),
filterTopicIds: Set<String> = emptySet(), filterTopicIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>> ): Flow<List<PopulatedNewsResource>>
@ -89,11 +82,6 @@ interface NewsResourceDao {
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef> newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>
) )
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
)
/** /**
* Deletes rows in the db matching the specified [ids] * 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, headerImageUrl = headerImageUrl,
publishDate = publishDate, publishDate = publishDate,
type = type, type = type,
authors = listOf(),
topics = listOf() topics = listOf()
) )

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

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

@ -35,7 +35,6 @@ class NiaPreferencesDataSource @Inject constructor(
UserData( UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys, bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,
followedTopics = it.followedTopicIdsMap.keys, followedTopics = it.followedTopicIdsMap.keys,
followedAuthors = it.followedAuthorIdsMap.keys,
themeBrand = when (it.themeBrand) { themeBrand = when (it.themeBrand) {
null, null,
ThemeBrandProto.THEME_BRAND_UNSPECIFIED, 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) { suspend fun setThemeBrand(themeBrand: ThemeBrand) {
userPreferences.updateData { userPreferences.updateData {
it.copy { it.copy {
@ -163,7 +131,6 @@ class NiaPreferencesDataSource @Inject constructor(
.map { .map {
ChangeListVersions( ChangeListVersions(
topicVersion = it.topicChangeListVersion, topicVersion = it.topicChangeListVersion,
authorVersion = it.authorChangeListVersion,
newsResourceVersion = it.newsResourceChangeListVersion, newsResourceVersion = it.newsResourceChangeListVersion,
) )
} }
@ -178,14 +145,12 @@ class NiaPreferencesDataSource @Inject constructor(
val updatedChangeListVersions = update( val updatedChangeListVersions = update(
ChangeListVersions( ChangeListVersions(
topicVersion = currentPreferences.topicChangeListVersion, topicVersion = currentPreferences.topicChangeListVersion,
authorVersion = currentPreferences.authorChangeListVersion,
newsResourceVersion = currentPreferences.newsResourceChangeListVersion newsResourceVersion = currentPreferences.newsResourceChangeListVersion
) )
) )
currentPreferences.copy { currentPreferences.copy {
topicChangeListVersion = updatedChangeListVersions.topicVersion topicChangeListVersion = updatedChangeListVersions.topicVersion
authorChangeListVersion = updatedChangeListVersions.authorVersion
newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion
} }
} }

@ -50,20 +50,6 @@ class NiaPreferencesDataSourceTest {
assertTrue(subject.userData.first().shouldHideOnboarding) 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 @Test
fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest { fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest {
@ -78,20 +64,6 @@ class NiaPreferencesDataSourceTest {
assertFalse(subject.userData.first().shouldHideOnboarding) 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 @Test
fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest { fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest {
@ -105,34 +77,4 @@ class NiaPreferencesDataSourceTest {
// Then: onboarding should be shown again // Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding) 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 * Returns a list of SaveableNewsResources which match the supplied set of topic ids.
* ids.
* *
* @param filterTopicIds - A set of topic ids used to filter the list of news resources. If * @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. * this is empty the list of news resources will not be filtered.
* @param filterAuthorIds - A set of author ids used to filter the list of news resources. If
* this is empty AND filterTopicIds is empty the list of news resources will not be filtered.
*
*/ */
operator fun invoke( operator fun invoke(
filterTopicIds: Set<String> = emptySet(), filterTopicIds: Set<String> = emptySet()
filterAuthorIds: Set<String> = emptySet()
): Flow<List<SaveableNewsResource>> = ): Flow<List<SaveableNewsResource>> =
if (filterTopicIds.isEmpty() && filterAuthorIds.isEmpty()) { if (filterTopicIds.isEmpty()) {
newsRepository.getNewsResources() newsRepository.getNewsResources()
} else { } else {
newsRepository.getNewsResources( newsRepository.getNewsResources(filterTopicIds = filterTopicIds)
filterTopicIds = filterTopicIds,
filterAuthorIds = filterAuthorIds
)
}.mapToSaveableNewsResources(bookmarkedNewsResources) }.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 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.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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -82,47 +81,6 @@ class GetSaveableNewsResourcesUseCaseTest {
saveableNewsResources.first() 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( private val sampleTopic1 = Topic(
@ -143,26 +101,6 @@ private val sampleTopic2 = Topic(
imageUrl = "image URL", imageUrl = "image URL",
) )
private val sampleAuthor1 =
Author(
id = "Author1",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleAuthor2 =
Author(
id = "Author2",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
private val sampleNewsResources = listOf( private val sampleNewsResources = listOf(
NewsResource( NewsResource(
id = "1", id = "1",
@ -175,8 +113,7 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = Video,
topics = listOf(sampleTopic1), topics = listOf(sampleTopic1)
authors = listOf(sampleAuthor1)
), ),
NewsResource( NewsResource(
id = "2", id = "2",
@ -188,8 +125,7 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = Video,
topics = listOf(sampleTopic1, sampleTopic2), topics = listOf(sampleTopic1, sampleTopic2)
authors = listOf(sampleAuthor1)
), ),
NewsResource( NewsResource(
id = "3", id = "3",
@ -199,7 +135,6 @@ private val sampleNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"), publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video, type = Video,
topics = listOf(sampleTopic2), topics = listOf(sampleTopic2)
authors = listOf(sampleAuthor2)
), ),
) )

@ -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 headerImageUrl: String?,
val publishDate: Instant, val publishDate: Instant,
val type: NewsResourceType, val type: NewsResourceType,
val authors: List<Author>,
val topics: List<Topic> 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", 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", 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", headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
authors = listOf(previewAuthors[0]),
publishDate = LocalDateTime( publishDate = LocalDateTime(
year = 2022, year = 2022,
monthNumber = 5, monthNumber = 5,
@ -71,7 +69,6 @@ val previewNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video, type = Video,
authors = listOf(previewAuthors[1]),
topics = listOf(previewTopics[0], previewTopics[1]) topics = listOf(previewTopics[0], previewTopics[1])
), ),
NewsResource( NewsResource(
@ -85,7 +82,6 @@ val previewNewsResources = listOf(
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video, type = Video,
authors = listOf(previewAuthors[0], previewAuthors[1]),
topics = listOf(previewTopics[2]) topics = listOf(previewTopics[2])
) )
) )

@ -22,7 +22,6 @@ package com.google.samples.apps.nowinandroid.core.model.data
data class UserData( data class UserData(
val bookmarkedNewsResources: Set<String>, val bookmarkedNewsResources: Set<String>,
val followedTopics: Set<String>, val followedTopics: Set<String>,
val followedAuthors: Set<String>,
val themeBrand: ThemeBrand, val themeBrand: ThemeBrand,
val darkThemeConfig: DarkThemeConfig, val darkThemeConfig: DarkThemeConfig,
val shouldHideOnboarding: Boolean 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 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.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic 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 { interface NiaNetworkDataSource {
suspend fun getTopics(ids: List<String>? = null): List<NetworkTopic> 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 getNewsResources(ids: List<String>? = null): List<NetworkNewsResource>
suspend fun getTopicChangeList(after: Int? = null): List<NetworkChangeList> suspend fun getTopicChangeList(after: Int? = null): List<NetworkChangeList>
suspend fun getAuthorChangeList(after: Int? = null): List<NetworkChangeList>
suspend fun getNewsResourceChangeList(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.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO 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.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.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic 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) 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> = override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
getTopics().mapToChangeList(NetworkTopic::id) getTopics().mapToChangeList(NetworkTopic::id)
override suspend fun getAuthorChangeList(after: Int?): List<NetworkChangeList> =
getAuthors().mapToChangeList(NetworkAuthor::id)
override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =
getNewsResources().mapToChangeList(NetworkNewsResource::id) 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, val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class) @Serializable(NewsResourceTypeSerializer::class)
val type: NewsResourceType, val type: NewsResourceType,
val authors: List<String> = listOf(),
val topics: List<String> = listOf(), val topics: List<String> = listOf(),
) )
@ -55,6 +54,5 @@ data class NetworkNewsResourceExpanded(
val publishDate: Instant, val publishDate: Instant,
@Serializable(NewsResourceTypeSerializer::class) @Serializable(NewsResourceTypeSerializer::class)
val type: NewsResourceType, val type: NewsResourceType,
val authors: List<NetworkAuthor> = listOf(),
val topics: List<NetworkTopic> = 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.BuildConfig
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource 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.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -44,11 +43,6 @@ private interface RetrofitNiaNetworkApi {
@Query("id") ids: List<String>?, @Query("id") ids: List<String>?,
): NetworkResponse<List<NetworkTopic>> ): NetworkResponse<List<NetworkTopic>>
@GET(value = "authors")
suspend fun getAuthors(
@Query("id") ids: List<String>?,
): NetworkResponse<List<NetworkAuthor>>
@GET(value = "newsresources") @GET(value = "newsresources")
suspend fun getNewsResources( suspend fun getNewsResources(
@Query("id") ids: List<String>?, @Query("id") ids: List<String>?,
@ -59,11 +53,6 @@ private interface RetrofitNiaNetworkApi {
@Query("after") after: Int?, @Query("after") after: Int?,
): List<NetworkChangeList> ): List<NetworkChangeList>
@GET(value = "changelists/authors")
suspend fun getAuthorsChangeList(
@Query("after") after: Int?,
): List<NetworkChangeList>
@GET(value = "changelists/newsresources") @GET(value = "changelists/newsresources")
suspend fun getNewsResourcesChangeList( suspend fun getNewsResourcesChangeList(
@Query("after") after: Int?, @Query("after") after: Int?,
@ -110,18 +99,12 @@ class RetrofitNiaNetwork @Inject constructor(
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
networkApi.getTopics(ids = ids).data 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> = override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
networkApi.getNewsResources(ids = ids).data networkApi.getNewsResources(ids = ids).data
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
networkApi.getTopicChangeList(after = after) networkApi.getTopicChangeList(after = after)
override suspend fun getAuthorChangeList(after: Int?): List<NetworkChangeList> =
networkApi.getAuthorsChangeList(after = after)
override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =
networkApi.getNewsResourcesChangeList(after = after) 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. ", 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", 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", headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
authors = listOf("25"),
publishDate = LocalDateTime( publishDate = LocalDateTime(
year = 2022, year = 2022,
monthNumber = 5, 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.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository 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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@ -36,16 +35,11 @@ class TestNewsRepository : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> = newsResourcesFlow override fun getNewsResources(): Flow<List<NewsResource>> = newsResourcesFlow
override fun getNewsResources( override fun getNewsResources(filterTopicIds: Set<String>): Flow<List<NewsResource>> =
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>
): Flow<List<NewsResource>> =
getNewsResources().map { newsResources -> getNewsResources().map { newsResources ->
newsResources newsResources.filter {
.filter { it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
it.authors.map(Author::id).intersect(filterAuthorIds).isNotEmpty() || }
it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
}
} }
/** /**

@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.filterNotNull
private val emptyUserData = UserData( private val emptyUserData = UserData(
bookmarkedNewsResources = emptySet(), bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(), followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false 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) { override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
currentUserData.let { current -> currentUserData.let { current ->
val bookmarkedNews = if (bookmarked) current.bookmarkedNewsResources + newsResourceId val bookmarkedNews = if (bookmarked) current.bookmarkedNewsResources + newsResourceId
@ -112,10 +98,4 @@ class TestUserDataRepository : UserDataRepository {
*/ */
fun getCurrentFollowedTopics(): Set<String>? = fun getCurrentFollowedTopics(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedTopics _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 package com.google.samples.apps.nowinandroid.core.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@ -44,9 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode 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.component.NiaTopicTag
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
@ -106,9 +99,6 @@ fun NewsResourceCardExpanded(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) { ) {
Column { Column {
Row {
NewsResourceAuthors(newsResource.authors)
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row { Row {
NewsResourceTitle( 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 @Composable
fun NewsResourceTitle( fun NewsResourceTitle(
newsResourceTitle: String, newsResourceTitle: String,

@ -136,7 +136,7 @@ Here's what's happening in each step. The easiest way to find the associated cod
</td> </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. <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> <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>
<td>Search for instances of <code>NewsFeedUiState.Success</code> <td>Search for instances of <code>NewsFeedUiState.Success</code>
</td> </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. 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 ### 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. 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 topics
* List of topic IDs which the current user is following * 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 ### 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> <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> Examples include:<br>
<ul> <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> <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> </ul>
</td> </td>
<td><code>AuthorScreen</code><br> <td><code>TopicScreen</code><br>
<code>AuthorViewModel</code> <code>TopicViewModel</code>
</td> </td>
</tr> </tr>
<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>Fetching app data from multiple sources, shared by different features.
</td> </td>
<td><code>TopicsRepository</code><br> <td><code>TopicsRepository</code><br>
<code>AuthorsRepository</code>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -219,7 +218,7 @@ Using the above modularization strategy, the Now in Android app has the followin
</td> </td>
<td>Model classes used throughout the app. <td>Model classes used throughout the app.
</td> </td>
<td><code>Author</code><br> <td><code>Topic</code><br>
<code>Episode</code><br> <code>Episode</code><br>
<code>NewsResource</code> <code>NewsResource</code>
</td> </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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource 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.Topic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -57,7 +55,6 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -80,7 +77,6 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.NotShown, onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()), feedState = NewsFeedUiState.Success(emptyList()),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -95,7 +91,7 @@ class ForYouScreenTest {
} }
@Test @Test
fun topicSelector_whenNoTopicsSelected_showsAuthorAndTopicChipsAndDisabledDoneButton() { fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
@ -103,26 +99,17 @@ class ForYouScreenTest {
onboardingUiState = onboardingUiState =
OnboardingUiState.Shown( OnboardingUiState.Shown(
topics = testTopics, topics = testTopics,
authors = testAuthors
), ),
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
} }
} }
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
@ -144,7 +131,7 @@ class ForYouScreenTest {
} }
@Test @Test
fun topicSelector_whenSomeTopicsSelected_showsAuthorAndTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
@ -154,79 +141,18 @@ class ForYouScreenTest {
// Follow one topic // Follow one topic
topics = testTopics.mapIndexed { index, testTopic -> topics = testTopics.mapIndexed { index, testTopic ->
testTopic.copy(isFollowed = index == 1) 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( feedState = NewsFeedUiState.Success(
feed = emptyList() feed = emptyList()
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
} }
} }
testAuthors.forEach { testAuthor ->
composeTestRule
.onNodeWithText(testAuthor.author.name)
.assertExists()
.assertHasClickAction()
}
testTopics.forEach { testTopic -> testTopics.forEach { testTopic ->
composeTestRule composeTestRule
.onNodeWithText(testTopic.topic.name) .onNodeWithText(testTopic.topic.name)
@ -254,13 +180,9 @@ class ForYouScreenTest {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = onboardingUiState =
OnboardingUiState.Shown( OnboardingUiState.Shown(topics = testTopics),
topics = testTopics,
authors = testAuthors
),
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -283,7 +205,6 @@ class ForYouScreenTest {
onboardingUiState = OnboardingUiState.NotShown, onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -309,7 +230,6 @@ class ForYouScreenTest {
} }
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -349,14 +269,6 @@ private val testTopic = Topic(
url = "", url = "",
imageUrl = "" imageUrl = ""
) )
private val testAuthor = Author(
id = "",
name = "",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = ""
)
private val testTopics = listOf( private val testTopics = listOf(
FollowableTopic( FollowableTopic(
topic = testTopic.copy(id = "0", name = "Headlines"), topic = testTopic.copy(id = "0", name = "Headlines"),
@ -371,13 +283,3 @@ private val testTopics = listOf(
isFollowed = false 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.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.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.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
@ -108,7 +106,6 @@ internal fun ForYouRoute(
onboardingUiState = onboardingUiState, onboardingUiState = onboardingUiState,
feedState = feedState, feedState = feedState,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
onAuthorCheckedChanged = viewModel::updateAuthorSelection,
saveFollowedTopics = viewModel::dismissOnboarding, saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
modifier = modifier modifier = modifier
@ -121,7 +118,6 @@ internal fun ForYouScreen(
onboardingUiState: OnboardingUiState, onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -162,7 +158,6 @@ internal fun ForYouScreen(
) { ) {
onboarding( onboarding(
onboardingUiState = onboardingUiState, onboardingUiState = onboardingUiState,
onAuthorCheckedChanged = onAuthorCheckedChanged,
onTopicCheckedChanged = onTopicCheckedChanged, onTopicCheckedChanged = onTopicCheckedChanged,
saveFollowedTopics = saveFollowedTopics, saveFollowedTopics = saveFollowedTopics,
// Custom LayoutModifier to remove the enforced parent 16.dp contentPadding // Custom LayoutModifier to remove the enforced parent 16.dp contentPadding
@ -224,7 +219,6 @@ internal fun ForYouScreen(
*/ */
private fun LazyGridScope.onboarding( private fun LazyGridScope.onboarding(
onboardingUiState: OnboardingUiState, onboardingUiState: OnboardingUiState,
onAuthorCheckedChanged: (String, Boolean) -> Unit,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
interestsItemModifier: Modifier = Modifier interestsItemModifier: Modifier = Modifier
@ -253,13 +247,6 @@ private fun LazyGridScope.onboarding(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
AuthorsCarousel(
authors = onboardingUiState.authors,
onAuthorClick = onAuthorCheckedChanged,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
TopicSelection( TopicSelection(
onboardingUiState, onboardingUiState,
onTopicCheckedChanged, onTopicCheckedChanged,
@ -414,7 +401,6 @@ fun ForYouScreenPopulatedFeed() {
} }
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -436,7 +422,6 @@ fun ForYouScreenOfflinePopulatedFeed() {
} }
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -453,7 +438,6 @@ fun ForYouScreenTopicSelection() {
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Shown( onboardingUiState = OnboardingUiState.Shown(
topics = previewTopics.map { FollowableTopic(it, false) }, topics = previewTopics.map { FollowableTopic(it, false) },
authors = previewAuthors.map { FollowableAuthor(it, false) }
), ),
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = previewNewsResources.map { feed = previewNewsResources.map {
@ -461,7 +445,6 @@ fun ForYouScreenTopicSelection() {
} }
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -479,7 +462,6 @@ fun ForYouScreenLoading() {
onboardingUiState = OnboardingUiState.Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } onNewsResourcesCheckedChanged = { _, _ -> }
) )
@ -501,7 +483,6 @@ fun ForYouScreenPopulatedAndLoading() {
} }
), ),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> } 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.data.util.SyncStatusMonitor
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase 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.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.domain.model.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -43,7 +42,6 @@ class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor, syncStatusMonitor: SyncStatusMonitor,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
private val getSaveableNewsResources: GetSaveableNewsResourcesUseCase, private val getSaveableNewsResources: GetSaveableNewsResourcesUseCase,
getSortedFollowableAuthors: GetSortedFollowableAuthorsUseCase,
getFollowableTopics: GetFollowableTopicsUseCase getFollowableTopics: GetFollowableTopicsUseCase
) : ViewModel() { ) : ViewModel() {
@ -64,14 +62,12 @@ class ForYouViewModel @Inject constructor(
// show an empty news list to clearly demonstrate that their selections affect the // show an empty news list to clearly demonstrate that their selections affect the
// news articles they will see. // news articles they will see.
if (!userData.shouldHideOnboarding && if (!userData.shouldHideOnboarding &&
userData.followedAuthors.isEmpty() &&
userData.followedTopics.isEmpty() userData.followedTopics.isEmpty()
) { ) {
flowOf(NewsFeedUiState.Success(emptyList())) flowOf(NewsFeedUiState.Success(emptyList()))
} else { } else {
getSaveableNewsResources( getSaveableNewsResources(
filterTopicIds = userData.followedTopics, filterTopicIds = userData.followedTopics
filterAuthorIds = userData.followedAuthors
).mapToFeedState() ).mapToFeedState()
} }
} }
@ -88,14 +84,10 @@ class ForYouViewModel @Inject constructor(
val onboardingUiState: StateFlow<OnboardingUiState> = val onboardingUiState: StateFlow<OnboardingUiState> =
combine( combine(
shouldShowOnboarding, shouldShowOnboarding,
getFollowableTopics(), getFollowableTopics()
getSortedFollowableAuthors() ) { shouldShowOnboarding, topics ->
) { shouldShowOnboarding, topics, authors ->
if (shouldShowOnboarding) { if (shouldShowOnboarding) {
OnboardingUiState.Shown( OnboardingUiState.Shown(topics = topics)
topics = topics,
authors = authors
)
} else { } else {
OnboardingUiState.NotShown 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) { fun updateNewsResourceSaved(newsResourceId: String, isChecked: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.updateNewsResourceBookmark(newsResourceId, isChecked) userDataRepository.updateNewsResourceBookmark(newsResourceId, isChecked)

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.feature.foryou 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 import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
/** /**
@ -39,16 +38,14 @@ sealed interface OnboardingUiState {
object NotShown : 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( data class Shown(
val topics: List<FollowableTopic>, val topics: List<FollowableTopic>
val authors: List<FollowableAuthor>
) : OnboardingUiState { ) : OnboardingUiState {
/** /**
* True if the onboarding can be dismissed. * True if the onboarding can be dismissed.
*/ */
val isDismissable: Boolean get() = val isDismissable: Boolean get() = topics.any { it.isFollowed }
topics.any { it.isFollowed } || authors.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.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase 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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource 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.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository 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.repository.TestUserDataRepository
@ -56,17 +52,12 @@ class ForYouViewModelTest {
private val networkMonitor = TestNetworkMonitor() private val networkMonitor = TestNetworkMonitor()
private val syncStatusMonitor = TestSyncStatusMonitor() private val syncStatusMonitor = TestSyncStatusMonitor()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase( private val getSaveableNewsResourcesUseCase = GetSaveableNewsResourcesUseCase(
newsRepository = newsRepository, newsRepository = newsRepository,
userDataRepository = userDataRepository userDataRepository = userDataRepository
) )
private val getSortedFollowableAuthors = GetSortedFollowableAuthorsUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userDataRepository = userDataRepository userDataRepository = userDataRepository
@ -79,7 +70,6 @@ class ForYouViewModelTest {
syncStatusMonitor = syncStatusMonitor, syncStatusMonitor = syncStatusMonitor,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getSaveableNewsResources = getSaveableNewsResourcesUseCase, getSaveableNewsResources = getSaveableNewsResourcesUseCase,
getSortedFollowableAuthors = getSortedFollowableAuthors,
getFollowableTopics = getFollowableTopicsUseCase getFollowableTopics = getFollowableTopicsUseCase
) )
} }
@ -126,24 +116,6 @@ class ForYouViewModelTest {
collectJob.cancel() 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 @Test
fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest { fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 = val collectJob1 =
@ -162,24 +134,6 @@ class ForYouViewModelTest {
collectJob2.cancel() 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 @Test
fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest { fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest {
val collectJob1 = val collectJob1 =
@ -188,8 +142,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
assertEquals( assertEquals(
OnboardingUiState.Shown( OnboardingUiState.Shown(
@ -228,41 +180,6 @@ class ForYouViewModelTest {
isFollowed = false 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 viewModel.onboardingUiState.value
) )
@ -278,15 +195,13 @@ class ForYouViewModelTest {
} }
@Test @Test
fun onboardingIsShownAfterLoadingEmptyFollowedTopicsAndAuthors() = runTest { fun onboardingIsShownAfterLoadingEmptyFollowedTopics() = runTest {
val collectJob1 = val collectJob1 =
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
assertEquals( assertEquals(
@ -326,41 +241,6 @@ class ForYouViewModelTest {
isFollowed = false 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 viewModel.onboardingUiState.value
) )
@ -382,8 +262,6 @@ class ForYouViewModelTest {
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() } val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("0", "1")) userDataRepository.setFollowedTopicIds(setOf("0", "1"))
viewModel.dismissOnboarding() viewModel.dismissOnboarding()
@ -425,8 +303,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
assertEquals( assertEquals(
@ -466,41 +342,6 @@ class ForYouViewModelTest {
isFollowed = false 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 viewModel.onboardingUiState.value
) )
@ -550,232 +391,6 @@ class ForYouViewModelTest {
isFollowed = false 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 viewModel.onboardingUiState.value
) )
@ -807,8 +422,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(emptySet())
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateTopicSelection("1", isChecked = true) viewModel.updateTopicSelection("1", isChecked = true)
viewModel.updateTopicSelection("1", isChecked = false) viewModel.updateTopicSelection("1", isChecked = false)
@ -851,142 +464,6 @@ class ForYouViewModelTest {
isFollowed = false 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 viewModel.onboardingUiState.value
) )
@ -1009,8 +486,6 @@ class ForYouViewModelTest {
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(setOf("1")) userDataRepository.setFollowedTopicIds(setOf("1"))
authorsRepository.sendAuthors(sampleAuthors)
userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setShouldHideOnboarding(true) userDataRepository.setShouldHideOnboarding(true)
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
viewModel.updateNewsResourceSaved("2", true) 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( private val sampleTopics = listOf(
Topic( Topic(
id = "0", id = "0",
@ -1116,16 +564,6 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL", imageUrl = "image URL",
) )
), ),
authors = listOf(
Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
)
), ),
NewsResource( NewsResource(
id = "2", id = "2",
@ -1147,16 +585,6 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL", imageUrl = "image URL",
), ),
), ),
authors = listOf(
Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
)
), ),
NewsResource( NewsResource(
id = "3", id = "3",
@ -1176,15 +604,5 @@ private val sampleNewsResources = listOf(
imageUrl = "image URL", 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.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.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.model.data.Topic
import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen
import com.google.samples.apps.nowinandroid.feature.interests.InterestsTabState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.R import com.google.samples.apps.nowinandroid.feature.interests.R
import org.junit.Before import org.junit.Before
@ -67,18 +64,7 @@ class InterestsScreenTest {
@Test @Test
fun niaLoadingWheel_inTopics_whenScreenIsLoading_showLoading() { fun niaLoadingWheel_inTopics_whenScreenIsLoading_showLoading() {
composeTestRule.setContent { composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Loading, tabIndex = 0) InterestsScreen(uiState = InterestsUiState.Loading)
}
composeTestRule
.onNodeWithContentDescription(interestsLoading)
.assertExists()
}
@Test
fun niaLoadingWheel_inAuthors_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Loading, tabIndex = 1)
} }
composeTestRule composeTestRule
@ -90,8 +76,7 @@ class InterestsScreenTest {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() { fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent { composeTestRule.setContent {
InterestsScreen( InterestsScreen(
uiState = InterestsUiState.Interests(topics = testTopics, authors = listOf()), uiState = InterestsUiState.Interests(topics = testTopics)
tabIndex = 0
) )
} }
@ -112,55 +97,12 @@ class InterestsScreenTest {
composeTestRule composeTestRule
.onAllNodesWithContentDescription(interestsTopicCardFollowButton) .onAllNodesWithContentDescription(interestsTopicCardFollowButton)
.assertCountEquals(numberOfUnfollowedTopics) .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 @Test
fun topicsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() { fun topicsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent { composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Empty, tabIndex = 0) InterestsScreen(uiState = InterestsUiState.Empty)
}
composeTestRule
.onNodeWithText(interestsEmptyHeader)
.assertIsDisplayed()
}
@Test
fun authorsEmpty_whenDataIsEmptyOccurs_thenShowEmptyScreen() {
composeTestRule.setContent {
InterestsScreen(uiState = InterestsUiState.Empty, tabIndex = 1)
} }
composeTestRule composeTestRule
@ -169,18 +111,11 @@ class InterestsScreenTest {
} }
@Composable @Composable
private fun InterestsScreen(uiState: InterestsUiState, tabIndex: Int = 0) { private fun InterestsScreen(uiState: InterestsUiState) {
InterestsScreen( InterestsScreen(
uiState = uiState, uiState = uiState,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = tabIndex
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> }, followTopic = { _, _ -> },
navigateToAuthor = {}, navigateToTopic = {}
navigateToTopic = {},
switchTab = {},
) )
} }
} }
@ -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 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground 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.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.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.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.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
@OptIn(ExperimentalLifecycleComposeApi::class) @OptIn(ExperimentalLifecycleComposeApi::class)
@Composable @Composable
internal fun InterestsRoute( internal fun InterestsRoute(
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit, navigateToTopic: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: InterestsViewModel = hiltViewModel() viewModel: InterestsViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val tabState by viewModel.tabState.collectAsStateWithLifecycle()
InterestsScreen( InterestsScreen(
uiState = uiState, uiState = uiState,
tabState = tabState,
followTopic = viewModel::followTopic, followTopic = viewModel::followTopic,
followAuthor = viewModel::followAuthor,
navigateToAuthor = navigateToAuthor,
navigateToTopic = navigateToTopic, navigateToTopic = navigateToTopic,
switchTab = viewModel::switchTab,
modifier = modifier modifier = modifier
) )
TrackDisposableJank(tabState) { metricsHolder ->
metricsHolder.state?.putState("Interests:TabState", "currentIndex:${tabState.currentIndex}")
onDispose {
metricsHolder.state?.removeState("Interests:TabState")
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun InterestsScreen( internal fun InterestsScreen(
uiState: InterestsUiState, uiState: InterestsUiState,
tabState: InterestsTabState,
followAuthor: (String, Boolean) -> Unit,
followTopic: (String, Boolean) -> Unit, followTopic: (String, Boolean) -> Unit,
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit, navigateToTopic: (String) -> Unit,
switchTab: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -93,56 +70,13 @@ internal fun InterestsScreen(
contentDesc = stringResource(id = R.string.interests_loading), contentDesc = stringResource(id = R.string.interests_loading),
) )
is InterestsUiState.Interests -> 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( TopicsTabContent(
topics = uiState.topics, topics = uiState.topics,
onTopicClick = navigateToTopic, onTopicClick = navigateToTopic,
onFollowButtonClick = followTopic, onFollowButtonClick = followTopic,
modifier = modifier,
) )
} is InterestsUiState.Empty -> InterestsEmptyScreen()
1 -> {
AuthorsTabContent(
authors = uiState.authors,
onAuthorClick = navigateToAuthor,
onFollowButtonClick = followAuthor,
)
}
} }
} }
} }
@ -159,18 +93,10 @@ fun InterestsScreenPopulated() {
NiaBackground { NiaBackground {
InterestsScreen( InterestsScreen(
uiState = InterestsUiState.Interests( uiState = InterestsUiState.Interests(
authors = previewAuthors.map { FollowableAuthor(it, false) },
topics = previewTopics.map { FollowableTopic(it, false) } topics = previewTopics.map { FollowableTopic(it, false) }
), ),
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> }, followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {}, navigateToTopic = {},
switchTab = {}
) )
} }
} }
@ -183,15 +109,8 @@ fun InterestsScreenLoading() {
NiaBackground { NiaBackground {
InterestsScreen( InterestsScreen(
uiState = InterestsUiState.Loading, uiState = InterestsUiState.Loading,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> }, followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {}, navigateToTopic = {},
switchTab = {},
) )
} }
} }
@ -204,15 +123,8 @@ fun InterestsScreenEmpty() {
NiaBackground { NiaBackground {
InterestsScreen( InterestsScreen(
uiState = InterestsUiState.Empty, uiState = InterestsUiState.Empty,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> }, followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {}, navigateToTopic = {},
switchTab = {}
) )
} }
} }

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

@ -23,13 +23,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
@Composable @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( fun NavGraphBuilder.interestsGraph(
navigateToTopic: (String) -> Unit, navigateToTopic: (String) -> Unit,
navigateToAuthor: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit nestedGraphs: NavGraphBuilder.() -> Unit
) { ) {
navigation( navigation(
@ -42,7 +41,6 @@ fun NavGraphBuilder.interestsGraph(
composable(route = interestsRoute) { composable(route = interestsRoute) {
InterestsRoute( InterestsRoute(
navigateToTopic = navigateToTopic, navigateToTopic = navigateToTopic,
navigateToAuthor = navigateToAuthor,
) )
} }
nestedGraphs() nestedGraphs()

@ -17,12 +17,8 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase 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.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.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.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository 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.core.testing.util.MainDispatcherRule
@ -47,17 +43,11 @@ class InterestsViewModelTest {
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase( private val getFollowableTopicsUseCase = GetFollowableTopicsUseCase(
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userDataRepository = userDataRepository userDataRepository = userDataRepository
) )
private val getSortedFollowableAuthors =
GetSortedFollowableAuthorsUseCase(
authorsRepository = authorsRepository,
userDataRepository = userDataRepository
)
private lateinit var viewModel: InterestsViewModel private lateinit var viewModel: InterestsViewModel
@Before @Before
@ -65,7 +55,6 @@ class InterestsViewModelTest {
viewModel = InterestsViewModel( viewModel = InterestsViewModel(
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
getSortedFollowableAuthors = getSortedFollowableAuthors
) )
} }
@ -78,31 +67,17 @@ class InterestsViewModelTest {
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest { fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(InterestsUiState.Loading, viewModel.uiState.value) assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel() 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 @Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest { fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
@ -118,29 +93,7 @@ class InterestsViewModelTest {
) )
assertEquals( assertEquals(
InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()), InterestsUiState.Interests(topics = testOutputTopics),
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),
viewModel.uiState.value viewModel.uiState.value
) )
@ -153,8 +106,6 @@ class InterestsViewModelTest {
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testOutputTopics.map { it.topic }) topicsRepository.sendTopics(testOutputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds( userDataRepository.setFollowedTopicIds(
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id) setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
@ -172,31 +123,7 @@ class InterestsViewModelTest {
) )
assertEquals( assertEquals(
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()), InterestsUiState.Interests(topics = testInputTopics),
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),
viewModel.uiState.value 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_URL = "URL"
private const val TOPIC_IMAGE_URL = "Image 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( private val testInputTopics = listOf(
FollowableTopic( FollowableTopic(
Topic( Topic(

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

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

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

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

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

Loading…
Cancel
Save