Adds topic screen

Bug: 213876743
Tests: Local + UI tests + Navigation test
Change-Id: I6c521695d6b777084a6255c6d62623a4def83063
pull/2/head
Jose Alcérreca 3 years ago committed by Don Turner
parent 9ebea8ad29
commit 553d152844

@ -118,6 +118,7 @@ android {
dependencies {
implementation project(':feature-following')
implementation project(':feature-foryou')
implementation project(':feature-topic')
implementation project(':core-ui')
@ -126,6 +127,7 @@ dependencies {
androidTestImplementation project(':core-testing')
androidTestImplementation project(':core-datastore-test')
androidTestImplementation project(':core-domain-test')
androidTestImplementation project(':core-network')
coreLibraryDesugaring libs.android.desugarJdkLibs

@ -183,4 +183,21 @@ class NavigationTest {
onNodeWithText(forYou).assertExists()
}
}
@Test
fun navigationBar_multipleBackStackFollowing() {
composeTestRule.apply {
onNodeWithText(topics).performClick()
onNodeWithText("Android Studio").performClick() // TODO: Grab string from fake data
// Switch tab
onNodeWithText(forYou).performClick()
// Come back to Following
onNodeWithText(topics).performClick()
// Verify we're not in the list of topics
onNodeWithText("Android Auto").assertDoesNotExist() // TODO: Grab string from fake data
}
}
}

@ -53,6 +53,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.google.samples.apps.nowinandroid.R
@ -69,16 +71,17 @@ fun NiaApp(windowSizeClass: SizeClass) {
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute =
navBackStackEntry?.destination?.route ?: NiaDestinations.FOR_YOU_ROUTE
val currentDestination = navBackStackEntry?.destination
Scaffold(
modifier = Modifier,
bottomBar = {
if (windowSizeClass.width == WidthSizeClass.Compact) NiABottomBar(
navigationActions = navigationActions,
currentRoute = currentRoute
)
if (windowSizeClass.width == WidthSizeClass.Compact) {
NiABottomBar(
navigationActions = navigationActions,
currentDestination = currentDestination
)
}
}
) { padding ->
Surface(Modifier.fillMaxSize().statusBarsPadding()) {
@ -86,7 +89,7 @@ fun NiaApp(windowSizeClass: SizeClass) {
if (windowSizeClass.width != WidthSizeClass.Compact) {
NiANavRail(
navigationActions = navigationActions,
currentRoute = currentRoute
currentDestination = currentDestination
)
}
NiaNavGraph(
@ -102,11 +105,12 @@ fun NiaApp(windowSizeClass: SizeClass) {
@Composable
private fun NiANavRail(
navigationActions: NiaNavigationActions,
currentRoute: String
currentDestination: NavDestination?
) {
NavigationRail {
TOP_LEVEL_DESTINATIONS.forEach { destination ->
val selected = currentRoute == destination.route
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
NavigationRailItem(
selected = selected,
onClick = { navigationActions.navigateToTopLevelDestination(destination.route) },
@ -125,7 +129,7 @@ private fun NiANavRail(
@Composable
private fun NiABottomBar(
navigationActions: NiaNavigationActions,
currentRoute: String
currentDestination: NavDestination?
) {
// Wrap the navigation bar in a surface so the color behind the system
// navigation is equal to the container color of the navigation bar.
@ -137,20 +141,26 @@ private fun NiABottomBar(
.captionBarPadding(),
tonalElevation = 0.dp
) {
TOP_LEVEL_DESTINATIONS.forEach { dst ->
val selected = currentRoute == dst.route
TOP_LEVEL_DESTINATIONS.forEach { destination ->
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
NavigationBarItem(
selected = selected,
onClick = {
navigationActions.navigateToTopLevelDestination(dst.route)
navigationActions.navigateToTopLevelDestination(destination.route)
},
icon = {
Icon(
if (selected) dst.selectedIcon else dst.unselectedIcon,
if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
},
contentDescription = null
)
},
label = { Text(stringResource(dst.iconTextId)) }
label = { Text(stringResource(destination.iconTextId)) }
)
}
}

@ -20,11 +20,18 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
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.following.FollowingRoute
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinations
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinationsArgs
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreens.TOPIC_SCREEN
/**
* Top-level navigation graph. Navigation is organized as explained at
@ -52,17 +59,26 @@ fun NiaNavGraph(
composable(NiaDestinations.SAVED_ROUTE) {
Text("SAVED", modifier)
}
composable(NiaDestinations.FOLLOWING_ROUTE) {
FollowingRoute(
navigateToTopic = { navController.navigate(NiaDestinations.TOPIC_ROUTE) },
modifier = modifier
)
}
composable(NiaDestinations.TOPIC_ROUTE) {
Text(
text = "Topic",
modifier = modifier
)
navigation(
startDestination = TopicDestinations.TOPICS_ROUTE,
route = NiaDestinations.FOLLOWING_ROUTE
) {
composable(TopicDestinations.TOPICS_ROUTE) {
FollowingRoute(
navigateToTopic = { navController.navigate("$TOPIC_SCREEN/$it") },
modifier = modifier
)
}
composable(
TopicDestinations.TOPIC_ROUTE,
arguments = listOf(
navArgument(TopicDestinationsArgs.TOPIC_ID_ARG) {
type = NavType.IntType
}
)
) {
TopicRoute(onBackClick = { navController.popBackStack() })
}
}
}
}

@ -29,7 +29,6 @@ object NiaDestinations {
const val EPISODES_ROUTE = "episodes"
const val SAVED_ROUTE = "saved"
const val FOLLOWING_ROUTE = "following"
const val TOPIC_ROUTE = "topic"
}
/**

@ -0,0 +1,35 @@
/*
* 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.result
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing>
object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> {
return this
.map<T, Result<T>> {
Result.Success(it)
}
.onStart { emit(Result.Loading) }
}

@ -30,6 +30,14 @@ import kotlinx.coroutines.flow.Flow
*/
@Dao
interface TopicDao {
@Query(
value = """
SELECT * FROM topics
WHERE id = :topicId
"""
)
fun getTopicEntity(topicId: Int): Flow<TopicEntity>
@Query(value = "SELECT * FROM topics")
fun getTopicEntitiesStream(): Flow<List<TopicEntity>>

@ -42,6 +42,9 @@ class LocalTopicsRepository @Inject constructor(
topicDao.getTopicEntitiesStream()
.map { it.map(TopicEntity::asExternalModel) }
override fun getTopic(id: Int): Flow<Topic> =
topicDao.getTopicEntity(id).map { it.asExternalModel() }
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) =
niaPreferences.setFollowedTopicIds(followedTopicIds)

@ -25,6 +25,11 @@ interface TopicsRepository {
*/
fun getTopicsStream(): Flow<List<Topic>>
/**
* Gets data for a specific topic
*/
fun getTopic(id: Int): Flow<Topic>
/**
* Sets the user's currently followed topics
*/

@ -28,6 +28,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
@ -59,6 +60,10 @@ class FakeTopicsRepository @Inject constructor(
}
.flowOn(ioDispatcher)
override fun getTopic(id: Int): Flow<Topic> {
return getTopicsStream().map { it.first { topic -> topic.id == id } }
}
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) =
niaPreferences.setFollowedTopicIds(followedTopicIds)

@ -58,7 +58,7 @@ class LocalTopicsRepositoryTest {
subject = LocalTopicsRepository(
topicDao = topicDao,
network = network,
niaPreferences = niaPreferences,
niaPreferences = niaPreferences
)
}

@ -40,6 +40,10 @@ class TestTopicDao : TopicDao {
)
)
override fun getTopicEntity(topicId: Int): Flow<TopicEntity> {
throw NotImplementedError("Unused in tests")
}
override fun getTopicEntitiesStream(): Flow<List<TopicEntity>> =
entitiesStateFlow

@ -63,4 +63,4 @@ dependencies {
force 'org.objenesis:objenesis:2.6'
}
}
}
}

@ -21,6 +21,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
class TestTopicsRepository : TopicsRepository {
/**
@ -37,6 +38,10 @@ class TestTopicsRepository : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> = topicsFlow
override fun getTopic(id: Int): Flow<Topic> {
return topicsFlow.map { topics -> topics.find { it.id == id }!! }
}
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) {
_followedTopicIds.tryEmit(followedTopicIds)
}

@ -16,6 +16,7 @@
<resources>
<string name="bookmark">Bookmark</string>
<string name="unbookmark">Unbookmark</string>
<string name="back">Back</string>
<string name="card_tap_action">Open Resource Link</string>
</resources>

@ -71,4 +71,4 @@ dependencies {
force 'org.objenesis:objenesis:2.6'
}
}
}
}

@ -55,7 +55,7 @@ import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
@Composable
fun FollowingRoute(
modifier: Modifier = Modifier,
navigateToTopic: () -> Unit,
navigateToTopic: (Int) -> Unit,
viewModel: FollowingViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@ -72,7 +72,7 @@ fun FollowingRoute(
fun FollowingScreen(
uiState: FollowingUiState,
followTopic: (Int, Boolean) -> Unit,
navigateToTopic: () -> Unit,
navigateToTopic: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -89,7 +89,7 @@ fun FollowingScreen(
is FollowingUiState.Topics ->
FollowingWithTopicsScreen(
uiState = uiState,
onTopicClick = { navigateToTopic() },
onTopicClick = navigateToTopic,
onFollowButtonClick = followTopic,
)
is FollowingUiState.Error -> FollowingErrorScreen()
@ -101,7 +101,7 @@ fun FollowingScreen(
fun FollowingWithTopicsScreen(
modifier: Modifier = Modifier,
uiState: FollowingUiState.Topics,
onTopicClick: () -> Unit,
onTopicClick: (Int) -> Unit,
onFollowButtonClick: (Int, Boolean) -> Unit
) {
LazyColumn(
@ -111,7 +111,7 @@ fun FollowingWithTopicsScreen(
item {
FollowingTopicCard(
followableTopic = followableTopic,
onTopicClick = onTopicClick,
onTopicClick = { onTopicClick(followableTopic.topic.id) },
onFollowButtonClick = onFollowButtonClick
)
}

@ -61,21 +61,29 @@ class FollowingViewModelTest {
@Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
awaitItem()
assertEquals(
false,
(awaitItem() as FollowingUiState.Topics)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
viewModel.followTopic(
followedTopicId = testInputTopics[1].topic.id,
followed = true
followedTopicId = toggleTopicId,
true
)
assertEquals(
FollowingUiState.Topics(topics = testOutputTopics),
awaitItem()
true,
(awaitItem() as FollowingUiState.Topics)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
cancel()
}
@ -83,6 +91,7 @@ class FollowingViewModelTest {
@Test
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test {
awaitItem()
@ -91,17 +100,21 @@ class FollowingViewModelTest {
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
)
awaitItem()
assertEquals(
true,
(awaitItem() as FollowingUiState.Topics)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
viewModel.followTopic(
followedTopicId = testOutputTopics[1].topic.id,
followed = false
followedTopicId = toggleTopicId,
false
)
assertEquals(
FollowingUiState.Topics(
topics = testInputTopics
),
awaitItem()
false,
(awaitItem() as FollowingUiState.Topics)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
cancel()
}

@ -0,0 +1 @@
/build

@ -0,0 +1,73 @@
/*
* 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 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk buildConfig.compileSdk
defaultConfig {
minSdk buildConfig.minSdk
targetSdk buildConfig.targetSdk
testInstrumentationRunner "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion libs.versions.androidxCompose.get()
}
}
dependencies {
implementation project(':core-model')
implementation project(':core-ui')
implementation project(':core-domain')
implementation project(':core-common')
testImplementation project(':core-testing')
androidTestImplementation project(':core-testing')
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
// 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,195 @@
/*
* 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.following
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.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.feature.topic.NewsUiState
import com.google.samples.apps.nowinandroid.feature.topic.R
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreenUiState
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState
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 Topic screen;
* Verifies that, when a specific UiState is set, the corresponding
* composables and details are shown
*/
class TopicScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var topicLoading: String
@Before
fun setup() {
composeTestRule.activity.apply {
topicLoading = getString(R.string.topic_loading)
}
}
@Test
fun niaLoadingIndicator_whenScreenIsLoading_showLoading() {
composeTestRule.setContent {
TopicScreen(
uiState = TopicScreenUiState(
topicState = TopicUiState.Loading,
newsState = NewsUiState.Loading
),
onBackClick = { },
onFollowClick = { }
)
}
composeTestRule
.onNodeWithContentDescription(topicLoading)
.assertExists()
}
@Test
fun topicTitle_whenTopicIsSuccess_isShown() {
val testTopic = testTopics.first()
composeTestRule.setContent {
TopicScreen(
uiState = TopicScreenUiState(
topicState = TopicUiState.Success(testTopic),
newsState = NewsUiState.Loading
),
onBackClick = { },
onFollowClick = { }
)
}
// Name is shown
composeTestRule
.onNodeWithText(testTopic.topic.name)
.assertExists()
// Description is shown
composeTestRule
.onNodeWithText(testTopic.topic.description)
.assertExists()
}
@Test
fun news_whenTopicIsLoading_isNotShown() {
val testTopic = testTopics.first()
composeTestRule.setContent {
TopicScreen(
uiState = TopicScreenUiState(
topicState = TopicUiState.Loading,
newsState = NewsUiState.Success(sampleNewsResources)
),
onBackClick = { },
onFollowClick = { }
)
}
// Loading indicator shown
composeTestRule
.onNodeWithContentDescription(topicLoading)
.assertExists()
}
@Test
fun news_whenSuccessAndTopicIsSuccess_isShown() {
val testTopic = testTopics.first()
composeTestRule.setContent {
TopicScreen(
uiState = TopicScreenUiState(
topicState = TopicUiState.Success(testTopic),
newsState = NewsUiState.Success(sampleNewsResources)
),
onBackClick = { },
onFollowClick = { }
)
}
// First news title shown
composeTestRule
.onNodeWithText(sampleNewsResources.first().title)
.assertExists()
}
}
private const val TOPIC_1_NAME = "Headlines"
private const val TOPIC_2_NAME = "UI"
private const val TOPIC_3_NAME = "Tools"
private const val TOPIC_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus qui."
private val testTopics = listOf(
FollowableTopic(
Topic(
id = 0,
name = TOPIC_1_NAME,
description = TOPIC_DESC,
),
isFollowed = true
),
FollowableTopic(
Topic(
id = 1,
name = TOPIC_2_NAME,
description = TOPIC_DESC
),
isFollowed = false
),
FollowableTopic(
Topic(
id = 2,
name = TOPIC_3_NAME,
description = TOPIC_DESC
),
isFollowed = false
)
)
private val numberOfUnfollowedTopics = testTopics.filter { !it.isFollowed }.size
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,
topics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
)
),
authors = emptyList()
)
)

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.feature.topic">
</manifest>

@ -0,0 +1,33 @@
/*
* 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.topic
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinationsArgs.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.TopicScreens.TOPIC_SCREEN
object TopicDestinations {
const val TOPICS_ROUTE = "topics"
const val TOPIC_ROUTE = "$TOPIC_SCREEN/{$TOPIC_ID_ARG}"
}
object TopicDestinationsArgs {
const val TOPIC_ID_ARG = "topicId"
}
object TopicScreens {
const val TOPIC_SCREEN = "topic"
}

@ -0,0 +1,193 @@
/*
* 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.topic
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Chip
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
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.graphics.Brush
import androidx.compose.ui.graphics.Color
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 com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.ui.NiaLoadingIndicator
import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@Composable
fun TopicRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),
) {
val uiState: TopicScreenUiState by viewModel.uiState.collectAsState()
TopicScreen(
topicState = uiState.topicState,
newsState = uiState.newsState,
modifier = modifier,
onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle,
)
}
@Composable
private fun TopicScreen(
topicState: TopicUiState,
newsState: NewsUiState,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
when (topicState) {
Loading ->
NiaLoadingIndicator(
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),
)
TopicUiState.Error -> TODO()
is TopicUiState.Success -> {
TopicToolbar(
onBackClick = onBackClick,
onFollowClick = onFollowClick,
uiState = topicState.followableTopic
)
TopicBody(
name = topicState.followableTopic.topic.name,
description = topicState.followableTopic.topic.longDescription,
news = newsState
)
}
}
}
}
@Composable
private fun TopicBody(name: String, description: String, news: NewsUiState) {
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
// TODO: Show icon if available
Box(
modifier = Modifier
.size(216.dp)
.align(Alignment.CenterHorizontally)
.background(
brush = Brush.radialGradient(
colors = listOf(Color.Black, Color.White)
)
)
.padding(bottom = 12.dp)
)
Text(name, style = MaterialTheme.typography.displayMedium)
if (description.isNotEmpty()) {
Text(
description,
modifier = Modifier.padding(top = 24.dp),
style = MaterialTheme.typography.bodyLarge
)
}
TopicList(news, Modifier.padding(top = 24.dp))
}
}
@Composable
private fun TopicList(news: NewsUiState, modifier: Modifier = Modifier) {
when (news) {
is NewsUiState.Success -> {
LazyColumn(modifier = modifier) {
items(news.news.size) { index ->
Text(news.news[index].title)
}
}
}
is NewsUiState.Loading -> {
NiaLoadingIndicator(contentDesc = "Loading news") // TODO
}
else -> {
Text("Error") // TODO
}
}
}
@Preview
@Composable
private fun TopicBodyPreview() {
MaterialTheme {
TopicBody("Jetpack Compose", "Lorem ipsum maximum", NewsUiState.Success(emptyList()))
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun TopicToolbar(
uiState: FollowableTopic,
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
Chip(onClick = { onFollowClick(!selected) }) {
if (selected) {
Icon(
imageVector = Filled.Check,
contentDescription = null
)
Text("FOLLOWING")
} else {
Text("NOT FOLLOWING")
}
}
}
}

@ -0,0 +1,117 @@
/*
* 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.topic
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
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 TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val topicsRepository: TopicsRepository,
newsRepository: NewsRepository
) : ViewModel() {
private val topicId: Int = checkNotNull(savedStateHandle[TopicDestinationsArgs.TOPIC_ID_ARG])
// Observe the followed topics, as they could change over time.
private val followedTopicIdsStream: Flow<Result<Set<Int>>> =
topicsRepository.getFollowedTopicIdsStream().asResult()
// Observe topic information
private val topic: Flow<Result<Topic>> = topicsRepository.getTopic(topicId).asResult()
// Observe the News for this topic
private val newsStream: Flow<Result<List<NewsResource>>> =
newsRepository.getNewsResourcesStream(setOf(topicId)).asResult()
val uiState: StateFlow<TopicScreenUiState> =
combine(
followedTopicIdsStream,
topic,
newsStream
) { followedTopicsResult, topicResult, newsResult ->
val topic: TopicUiState =
if (topicResult is Result.Success && followedTopicsResult is Result.Success) {
val followed = followedTopicsResult.data.contains(topicId)
TopicUiState.Success(
followableTopic = FollowableTopic(
topic = topicResult.data,
isFollowed = followed
)
)
} else if (
topicResult is Result.Loading || followedTopicsResult is Result.Loading
) {
TopicUiState.Loading
} else {
TopicUiState.Error
}
val news: NewsUiState = when (newsResult) {
is Result.Success -> NewsUiState.Success(newsResult.data)
is Result.Loading -> NewsUiState.Loading
is Result.Error -> NewsUiState.Error
}
TopicScreenUiState(topic, news)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TopicScreenUiState(TopicUiState.Loading, NewsUiState.Loading)
)
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {
topicsRepository.toggleFollowedTopicId(topicId, followed)
}
}
}
sealed interface TopicUiState {
data class Success(val followableTopic: FollowableTopic) : TopicUiState
object Error : TopicUiState
object Loading : TopicUiState
}
sealed interface NewsUiState {
data class Success(val news: List<NewsResource>) : NewsUiState
object Error : NewsUiState
object Loading : NewsUiState
}
data class TopicScreenUiState(
val topicState: TopicUiState,
val newsState: NewsUiState
)

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2022 Google LLC
~
~ 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.
-->
<resources>
<string name="topic">Topic</string>
<string name="topic_loading">Loading topic</string>
</resources>

@ -0,0 +1,237 @@
/*
* 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.following
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
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.util.TestDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.NewsUiState
import com.google.samples.apps.nowinandroid.feature.topic.TopicDestinationsArgs.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState
import com.google.samples.apps.nowinandroid.feature.topic.TopicViewModel
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 TopicViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private lateinit var viewModel: TopicViewModel
@Before
fun setup() {
viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
topicsRepository = topicsRepository,
newsRepository = newsRepository
)
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState)
cancel()
}
}
@Test
fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(TopicUiState.Loading, awaitItem().topicState)
cancel()
}
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, awaitItem().topicState)
cancel()
}
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =
runTest {
viewModel.uiState.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
cancel()
}
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
viewModel.uiState.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
cancel()
}
}
@Test
fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic })
// Set which topic IDs are followed, not including 0.
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
viewModel.followTopicToggle(true)
assertEquals(
TopicUiState.Success(followableTopic = testOutputTopics[0]),
awaitItem().topicState
)
cancel()
}
}
}
private const val TOPIC_1_NAME = "Android Studio"
private const val TOPIC_2_NAME = "Build"
private const val TOPIC_3_NAME = "Compose"
private const val TOPIC_SHORT_DESC = "At vero eos et accusamus."
private const val TOPIC_LONG_DESC = "At vero eos et accusamus et iusto odio dignissimos ducimus."
private const val TOPIC_URL = "URL"
private const val TOPIC_IMAGE_URL = "Image URL"
private val testInputTopics = listOf(
FollowableTopic(
Topic(
id = 0,
name = TOPIC_1_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true
),
FollowableTopic(
Topic(
id = 1,
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false
),
FollowableTopic(
Topic(
id = 2,
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = false
)
)
private val testOutputTopics = listOf(
FollowableTopic(
Topic(
id = 0,
name = TOPIC_1_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true
),
FollowableTopic(
Topic(
id = 1,
name = TOPIC_2_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_IMAGE_URL,
),
isFollowed = true
),
FollowableTopic(
Topic(
id = 2,
name = TOPIC_3_NAME,
shortDescription = TOPIC_SHORT_DESC,
longDescription = TOPIC_LONG_DESC,
url = TOPIC_URL,
imageUrl = TOPIC_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,
topics = listOf(
Topic(
id = 0,
name = "Headlines",
shortDescription = "",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
),
authors = emptyList()
)
)

@ -46,4 +46,5 @@ include ':core-ui'
include ':core-testing'
include ':feature-following'
include ':feature-foryou'
include ':feature-topic'
include ':sync'

Loading…
Cancel
Save