From d945a007c6ef9d52553643d8198618100894f459 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 2 May 2022 17:36:44 -0400 Subject: [PATCH] Add AuthorScreen and corresponding components Change-Id: Iee5e8bd0932d1b68770aaa1e23fa660451109601 --- app/build.gradle.kts | 1 + .../apps/nowinandroid/ui/NiaNavGraph.kt | 16 +- .../core/data/repository/AuthorsRepository.kt | 5 + .../OfflineFirstAuthorsRepository.kt | 5 + .../repository/fake/FakeAuthorsRepository.kt | 5 + .../core/data/testdoubles/TestAuthorDao.kt | 4 + .../core/database/dao/AuthorDao.kt | 8 + .../repository/TestAuthorsRepository.kt | 9 +- feature-author/.gitignore | 1 + feature-author/build.gradle.kts | 64 +++++ .../feature/author/AuthorScreenTest.kt | 191 +++++++++++++ feature-author/src/main/AndroidManifest.xml | 20 ++ .../feature/author/AuthorScreen.kt | 249 +++++++++++++++++ .../feature/author/AuthorViewModel.kt | 124 +++++++++ .../nowinandroid/feature/author/Navigation.kt | 32 +++ .../src/main/res/values/strings.xml | 22 ++ .../feature/author/AuthorViewModelTest.kt | 262 ++++++++++++++++++ .../feature/interests/InterestsScreen.kt | 17 +- .../feature/interests/TabContent.kt | 14 +- settings.gradle.kts | 1 + 20 files changed, 1034 insertions(+), 16 deletions(-) create mode 100644 feature-author/.gitignore create mode 100644 feature-author/build.gradle.kts create mode 100644 feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt create mode 100644 feature-author/src/main/AndroidManifest.xml create mode 100644 feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt create mode 100644 feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt create mode 100644 feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/Navigation.kt create mode 100644 feature-author/src/main/res/values/strings.xml create mode 100644 feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8761fbd10..cc60c587f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,7 @@ android { } dependencies { + implementation(project(":feature-author")) implementation(project(":feature-interests")) implementation(project(":feature-foryou")) implementation(project(":feature-topic")) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt index 288649fbc..098d7f22b 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavGraph.kt @@ -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() }) + } } } } diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt index 6d931d27a..6921b94b3 100644 --- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt +++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AuthorsRepository.kt @@ -26,6 +26,11 @@ interface AuthorsRepository : Syncable { */ fun getAuthorsStream(): Flow> + /** + * Gets data for a specific author + */ + fun getAuthorStream(id: String): Flow + /** * Sets the user's currently followed authors */ diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt index 1ff540180..9dc1f1eef 100644 --- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt +++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstAuthorsRepository.kt @@ -41,6 +41,11 @@ class OfflineFirstAuthorsRepository @Inject constructor( private val niaPreferences: NiaPreferences, ) : AuthorsRepository { + override fun getAuthorStream(id: String): Flow = + authorDao.getAuthorEntityStream(id).map { + it.asExternalModel() + } + override fun getAuthorsStream(): Flow> = authorDao.getAuthorEntitiesStream() .map { it.map(AuthorEntity::asExternalModel) } diff --git a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt index 85bf19cf2..7bcfa02fd 100644 --- a/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt +++ b/core-data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeAuthorsRepository.kt @@ -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 { + return getAuthorsStream().map { it.first { author -> author.id == id } } + } + override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) { niaPreferences.setFollowedAuthorIds(followedAuthorIds) } diff --git a/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestAuthorDao.kt b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestAuthorDao.kt index 295688a61..84a27fd28 100644 --- a/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestAuthorDao.kt +++ b/core-data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestAuthorDao.kt @@ -43,6 +43,10 @@ class TestAuthorDao : AuthorDao { override fun getAuthorEntitiesStream(): Flow> = entitiesStateFlow + override fun getAuthorEntityStream(authorId: String): Flow { + throw NotImplementedError("Unused in tests") + } + override suspend fun insertOrIgnoreAuthors(authorEntities: List): List { entitiesStateFlow.value = authorEntities // Assume no conflicts on insert diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt index 35c87678f..8c3726ed1 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt @@ -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 + @Query(value = "SELECT * FROM authors") fun getAuthorEntitiesStream(): Flow> diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt index d8ac74b39..c3f69d921 100644 --- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt +++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt @@ -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> = authorsFlow + override fun getAuthorStream(id: String): Flow { + return authorsFlow.map { authors -> authors.find { it.id == id }!! } + } + override fun getFollowedAuthorIdsStream(): Flow> = _followedAuthorIds override suspend fun setFollowedAuthorIds(followedAuthorIds: Set) { @@ -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) { 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? = _followedAuthorIds.replayCache.firstOrNull() } diff --git a/feature-author/.gitignore b/feature-author/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature-author/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-author/build.gradle.kts b/feature-author/build.gradle.kts new file mode 100644 index 000000000..172e25654 --- /dev/null +++ b/feature-author/build.gradle.kts @@ -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") + } + } +} diff --git a/feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt b/feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt new file mode 100644 index 000000000..02e950de0 --- /dev/null +++ b/feature-author/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreenTest.kt @@ -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() + + 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! Here’s 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() + ) +) diff --git a/feature-author/src/main/AndroidManifest.xml b/feature-author/src/main/AndroidManifest.xml new file mode 100644 index 000000000..bd1b40b71 --- /dev/null +++ b/feature-author/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt new file mode 100644 index 000000000..69225e7a3 --- /dev/null +++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorScreen.kt @@ -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()) + ) + } + } +} diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt new file mode 100644 index 000000000..656015e5d --- /dev/null +++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModel.kt @@ -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>> = + authorsRepository.getFollowedAuthorIdsStream().asResult() + + // Observe author information + private val author: Flow> = authorsRepository.getAuthorStream( + id = authorId + ).asResult() + + // Observe the News for this author + private val newsStream: Flow>> = + newsRepository.getNewsResourcesStream( + filterAuthorIds = setOf(element = authorId), + filterTopicIds = emptySet() + ).asResult() + + val uiState: StateFlow = + 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) : NewsUiState + object Error : NewsUiState + object Loading : NewsUiState +} + +data class AuthorScreenUiState( + val authorState: AuthorUiState, + val newsState: NewsUiState +) diff --git a/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/Navigation.kt b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/Navigation.kt new file mode 100644 index 000000000..52c41d832 --- /dev/null +++ b/feature-author/src/main/java/com/google/samples/apps/nowinandroid/feature/author/Navigation.kt @@ -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" +} diff --git a/feature-author/src/main/res/values/strings.xml b/feature-author/src/main/res/values/strings.xml new file mode 100644 index 000000000..84b479668 --- /dev/null +++ b/feature-author/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Author + Loading author + FOLLOWING + NOT FOLLOWING + diff --git a/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt b/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt new file mode 100644 index 000000000..2779b6e49 --- /dev/null +++ b/feature-author/src/test/java/com/google/samples/apps/nowinandroid/feature/author/AuthorViewModelTest.kt @@ -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! Here’s 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() + ) +) diff --git a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index cb6039d9f..7be087ccb 100644 --- a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -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) ) diff --git a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 19eadcf92..55efafc33 100644 --- a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -70,21 +70,21 @@ fun TopicsTabContent( @Composable fun AuthorsTabContent( authors: List, - 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) ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index bd72f719f..7464b9b7f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")