Add AuthorScreen and corresponding components

Change-Id: Iee5e8bd0932d1b68770aaa1e23fa660451109601
pull/2/head
Adetunji Dahunsi 2 years ago committed by Don Turner
parent dc4af7e6f7
commit d945a007c6

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

@ -27,6 +27,10 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.author.AuthorDestinations
import com.google.samples.apps.nowinandroid.feature.author.AuthorDestinationsArgs
import com.google.samples.apps.nowinandroid.feature.author.AuthorRoute
import com.google.samples.apps.nowinandroid.feature.author.InterestsScreens.AUTHOR_SCREEN
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.topic.InterestsDestinations
@ -69,7 +73,7 @@ fun NiaNavGraph(
composable(InterestsDestinations.INTERESTS_DESTINATION) {
InterestsRoute(
navigateToTopic = { navController.navigate("$TOPIC_SCREEN/$it") },
navigateToAuthor = { /* TO IMPLEMENT */ },
navigateToAuthor = { navController.navigate("$AUTHOR_SCREEN/$it") },
)
}
composable(
@ -82,6 +86,16 @@ fun NiaNavGraph(
) {
TopicRoute(onBackClick = { navController.popBackStack() })
}
composable(
AuthorDestinations.AUTHOR_ROUTE,
arguments = listOf(
navArgument(AuthorDestinationsArgs.AUTHOR_ID_ARG) {
type = NavType.StringType
}
)
) {
AuthorRoute(onBackClick = { navController.popBackStack() })
}
}
}
}

@ -26,6 +26,11 @@ interface AuthorsRepository : Syncable {
*/
fun getAuthorsStream(): Flow<List<Author>>
/**
* Gets data for a specific author
*/
fun getAuthorStream(id: String): Flow<Author>
/**
* Sets the user's currently followed authors
*/

@ -41,6 +41,11 @@ class OfflineFirstAuthorsRepository @Inject constructor(
private val niaPreferences: NiaPreferences,
) : AuthorsRepository {
override fun getAuthorStream(id: String): Flow<Author> =
authorDao.getAuthorEntityStream(id).map {
it.asExternalModel()
}
override fun getAuthorsStream(): Flow<List<Author>> =
authorDao.getAuthorEntitiesStream()
.map { it.map(AuthorEntity::asExternalModel) }

@ -29,6 +29,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
@ -60,6 +61,10 @@ class FakeAuthorsRepository @Inject constructor(
}
.flowOn(ioDispatcher)
override fun getAuthorStream(id: String): Flow<Author> {
return getAuthorsStream().map { it.first { author -> author.id == id } }
}
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
niaPreferences.setFollowedAuthorIds(followedAuthorIds)
}

@ -43,6 +43,10 @@ class TestAuthorDao : AuthorDao {
override fun getAuthorEntitiesStream(): Flow<List<AuthorEntity>> =
entitiesStateFlow
override fun getAuthorEntityStream(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

@ -30,6 +30,14 @@ import kotlinx.coroutines.flow.Flow
*/
@Dao
interface AuthorDao {
@Query(
value = """
SELECT * FROM authors
WHERE id = :authorId
"""
)
fun getAuthorEntityStream(authorId: String): Flow<AuthorEntity>
@Query(value = "SELECT * FROM authors")
fun getAuthorEntitiesStream(): Flow<List<AuthorEntity>>

@ -22,6 +22,7 @@ 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 {
/**
@ -38,6 +39,10 @@ class TestAuthorsRepository : AuthorsRepository {
override fun getAuthorsStream(): Flow<List<Author>> = authorsFlow
override fun getAuthorStream(id: String): Flow<Author> {
return authorsFlow.map { authors -> authors.find { it.id == id }!! }
}
override fun getFollowedAuthorIdsStream(): Flow<Set<String>> = _followedAuthorIds
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
@ -56,14 +61,14 @@ class TestAuthorsRepository : AuthorsRepository {
override suspend fun syncWith(synchronizer: Synchronizer) = true
/**
* A test-only API to allow controlling the list of topics from tests.
* A test-only API to allow controlling the list of authors from tests.
*/
fun sendAuthors(authors: List<Author>) {
authorsFlow.tryEmit(authors)
}
/**
* A test-only API to allow querying the current followed topics.
* A test-only API to allow querying the current followed authors.
*/
fun getCurrentFollowedAuthors(): Set<String>? = _followedAuthorIds.replayCache.firstOrNull()
}

@ -0,0 +1 @@
/build

@ -0,0 +1,64 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
}
android {
defaultConfig {
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
}
dependencies {
implementation(project(":core-model"))
implementation(project(":core-ui"))
implementation(project(":core-data"))
implementation(project(":core-common"))
testImplementation(project(":core-testing"))
androidTestImplementation(project(":core-testing"))
implementation(libs.coil.kt)
implementation(libs.coil.kt.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
// TODO : Remove this dependency once we upgrade to Android Studio Dolphin b/228889042
// These dependencies are currently necessary to render Compose previews
debugImplementation(libs.androidx.customview.poolingcontainer)
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13
configurations.configureEach {
resolutionStrategy {
force(libs.junit4)
// Temporary workaround for https://issuetracker.google.com/174733673
force("org.objenesis:objenesis:2.6")
}
}
}

@ -0,0 +1,191 @@
/*
* 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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import 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(
authorState = AuthorUiState.Loading,
newsState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { }
)
}
composeTestRule
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
@Test
fun authorTitle_whenAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorState = AuthorUiState.Success(testAuthor),
newsState = NewsUiState.Loading,
onBackClick = { },
onFollowClick = { }
)
}
// 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(
authorState = AuthorUiState.Loading,
newsState = NewsUiState.Success(sampleNewsResources),
onBackClick = { },
onFollowClick = { }
)
}
// Loading indicator shown
composeTestRule
.onNodeWithContentDescription(authorLoading)
.assertExists()
}
@Test
fun news_whenSuccessAndAuthorIsSuccess_isShown() {
val testAuthor = testAuthors.first()
composeTestRule.setContent {
AuthorScreen(
authorState = AuthorUiState.Success(testAuthor),
newsState = NewsUiState.Success(sampleNewsResources),
onBackClick = { },
onFollowClick = { }
)
}
// 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",
episodeId = "52",
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()
)
)

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

@ -0,0 +1,249 @@
/*
* 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.ExperimentalFoundationApi
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.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.ArrowBack
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.collectAsState
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.author.AuthorUiState.Loading
import com.google.samples.apps.nowinandroid.feature.author.R.string
@Composable
fun AuthorRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AuthorViewModel = hiltViewModel(),
) {
val uiState: AuthorScreenUiState by viewModel.uiState.collectAsState()
AuthorScreen(
authorState = uiState.authorState,
newsState = uiState.newsState,
modifier = modifier,
onBackClick = onBackClick,
onFollowClick = viewModel::followAuthorToggle,
)
}
@OptIn(ExperimentalFoundationApi::class)
@VisibleForTesting
@Composable
internal fun AuthorScreen(
authorState: AuthorUiState,
newsState: NewsUiState,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Spacer(
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
}
when (authorState) {
Loading -> {
item {
LoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = string.author_loading),
)
}
}
AuthorUiState.Error -> {
TODO()
}
is AuthorUiState.Success -> {
item {
AuthorToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
uiState = authorState.followableAuthor,
)
}
authorBody(
author = authorState.followableAuthor.author,
news = newsState
)
}
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
}
}
}
private fun LazyListScope.authorBody(
author: Author,
news: NewsUiState
) {
item {
AuthorHeader(author)
}
authorCards(news)
}
@Composable
private fun AuthorHeader(author: Author) {
Column(
modifier = Modifier.padding(horizontal = 24.dp)
) {
AsyncImage(
modifier = Modifier
.size(216.dp)
.align(Alignment.CenterHorizontally)
.clip(CircleShape)
.padding(bottom = 12.dp),
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) {
when (news) {
is NewsUiState.Success -> {
newsResourceCardItems(
items = news.news,
newsResourceMapper = { it },
isBookmarkedMapper = { /* TODO */ false },
onToggleBookmark = { /* TODO */ },
itemModifier = Modifier.padding(24.dp)
)
}
is NewsUiState.Loading -> item {
LoadingWheel(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 = Filled.ArrowBack,
contentDescription = stringResource(id = R.string.back)
)
}
val selected = uiState.isFollowed
NiaFilterChip(
checked = selected,
onCheckedChange = onFollowClick,
) {
if (selected) {
Text(stringResource(id = string.author_following))
} else {
Text(stringResource(id = string.author_not_following))
}
}
}
}
@Preview
@Composable
private fun AuthorBodyPreview() {
MaterialTheme {
LazyColumn {
authorBody(
author = Author(
id = "0",
name = "Android Dev",
bio = "Works on Compose",
twitter = "dev",
mediumPage = "",
imageUrl = "",
),
news = NewsUiState.Success(emptyList())
)
}
}
}

@ -0,0 +1,124 @@
/*
* 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.NewsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
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.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AuthorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authorsRepository: AuthorsRepository,
newsRepository: NewsRepository
) : ViewModel() {
private val authorId: String = checkNotNull(
savedStateHandle[AuthorDestinationsArgs.AUTHOR_ID_ARG]
)
// Observe the followed authors, as they could change over time.
private val followedAuthorIdsStream: Flow<Result<Set<String>>> =
authorsRepository.getFollowedAuthorIdsStream().asResult()
// Observe author information
private val author: Flow<Result<Author>> = authorsRepository.getAuthorStream(
id = authorId
).asResult()
// Observe the News for this author
private val newsStream: Flow<Result<List<NewsResource>>> =
newsRepository.getNewsResourcesStream(
filterAuthorIds = setOf(element = authorId),
filterTopicIds = emptySet()
).asResult()
val uiState: StateFlow<AuthorScreenUiState> =
combine(
followedAuthorIdsStream,
author,
newsStream
) { followedAuthorsResult, authorResult, newsResult ->
val author: AuthorUiState =
if (authorResult is Result.Success && followedAuthorsResult is Result.Success) {
val followed = followedAuthorsResult.data.contains(authorId)
AuthorUiState.Success(
followableAuthor = FollowableAuthor(
author = authorResult.data,
isFollowed = followed
)
)
} else if (
authorResult is Result.Loading || followedAuthorsResult is Result.Loading
) {
AuthorUiState.Loading
} else {
AuthorUiState.Error
}
val news: NewsUiState = when (newsResult) {
is Result.Success -> NewsUiState.Success(newsResult.data)
is Result.Loading -> NewsUiState.Loading
is Result.Error -> NewsUiState.Error
}
AuthorScreenUiState(author, news)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AuthorScreenUiState(AuthorUiState.Loading, NewsUiState.Loading)
)
fun followAuthorToggle(followed: Boolean) {
viewModelScope.launch {
authorsRepository.toggleFollowedAuthorId(authorId, followed)
}
}
}
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<NewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}
data class AuthorScreenUiState(
val authorState: AuthorUiState,
val newsState: NewsUiState
)

@ -0,0 +1,32 @@
/*
* 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 com.google.samples.apps.nowinandroid.feature.author.AuthorDestinationsArgs.AUTHOR_ID_ARG
import com.google.samples.apps.nowinandroid.feature.author.InterestsScreens.AUTHOR_SCREEN
object AuthorDestinations {
const val AUTHOR_ROUTE = "$AUTHOR_SCREEN/{$AUTHOR_ID_ARG}"
}
object AuthorDestinationsArgs {
const val AUTHOR_ID_ARG = "authorId"
}
object InterestsScreens {
const val AUTHOR_SCREEN = "author"
}

@ -0,0 +1,22 @@
<?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>

@ -0,0 +1,262 @@
/*
* 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 app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule
import com.google.samples.apps.nowinandroid.feature.author.AuthorDestinationsArgs.AUTHOR_ID_ARG
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class AuthorViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
private val authorsRepository = TestAuthorsRepository()
private val newsRepository = TestNewsRepository()
private lateinit var viewModel: AuthorViewModel
@Before
fun setup() {
viewModel = AuthorViewModel(
savedStateHandle = SavedStateHandle(
mapOf(
AUTHOR_ID_ARG to testInputAuthors[0].author.id
)
),
authorsRepository = authorsRepository,
newsRepository = newsRepository
)
}
@Test
fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest {
viewModel.uiState.test {
awaitItem()
// To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success)
val successAuthorUiState = item.authorState as AuthorUiState.Success
val authorFromRepository = authorsRepository.getAuthorStream(
id = testInputAuthors[0].author.id
).first()
successAuthorUiState.followableAuthor.author
assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author)
cancel()
}
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState)
cancel()
}
}
@Test
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(AuthorUiState.Loading, awaitItem().authorState)
cancel()
}
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, awaitItem().authorState)
cancel()
}
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() =
runTest {
viewModel.uiState.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
cancel()
}
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
viewModel.uiState.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
cancel()
}
}
@Test
fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest {
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
// Set which author IDs are followed, not including 0.
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
viewModel.followAuthorToggle(true)
assertEquals(
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
awaitItem().authorState
)
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",
episodeId = "52",
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()
)
)

@ -44,7 +44,7 @@ import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar
@Composable
fun InterestsRoute(
modifier: Modifier = Modifier,
navigateToAuthor: () -> Unit,
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit,
viewModel: InterestsViewModel = hiltViewModel()
) {
@ -69,7 +69,7 @@ fun InterestsScreen(
tabState: InterestsTabState,
followAuthor: (String, Boolean) -> Unit,
followTopic: (String, Boolean) -> Unit,
navigateToAuthor: () -> Unit,
navigateToAuthor: (String) -> Unit,
navigateToTopic: (String) -> Unit,
switchTab: (Int) -> Unit,
modifier: Modifier = Modifier,
@ -105,8 +105,13 @@ fun InterestsScreen(
)
is InterestsUiState.Interests ->
InterestsContent(
tabState, switchTab, uiState, navigateToTopic, followTopic,
navigateToAuthor, followAuthor
tabState = tabState,
switchTab = switchTab,
uiState = uiState,
navigateToTopic = navigateToTopic,
followTopic = followTopic,
navigateToAuthor = navigateToAuthor,
followAuthor = followAuthor
)
is InterestsUiState.Empty -> InterestsEmptyScreen()
}
@ -120,7 +125,7 @@ private fun InterestsContent(
uiState: InterestsUiState.Interests,
navigateToTopic: (String) -> Unit,
followTopic: (String, Boolean) -> Unit,
navigateToAuthor: () -> Unit,
navigateToAuthor: (String) -> Unit,
followAuthor: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
@ -146,7 +151,7 @@ private fun InterestsContent(
1 -> {
AuthorsTabContent(
authors = uiState.authors,
onAuthorClick = { navigateToAuthor() },
onAuthorClick = navigateToAuthor,
onFollowButtonClick = followAuthor,
modifier = Modifier.padding(top = 8.dp)
)

@ -70,21 +70,21 @@ fun TopicsTabContent(
@Composable
fun AuthorsTabContent(
authors: List<FollowableAuthor>,
onAuthorClick: () -> Unit,
onAuthorClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp)
) {
authors.forEach { followableTopic ->
authors.forEach { followableAuthor ->
item {
InterestsItem(
name = followableTopic.author.name,
following = followableTopic.isFollowed,
topicImageUrl = followableTopic.author.imageUrl,
onClick = onAuthorClick,
onFollowButtonClick = { onFollowButtonClick(followableTopic.author.id, it) },
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)
)
}

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

Loading…
Cancel
Save