diff --git a/app/build.gradle b/app/build.gradle index f5aceda74..bfc8366d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 6a8312c67..526dbb24a 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -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 + } + } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 5073d8cad..bc44c8240 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -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)) } ) } } 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 7dafa94b9..201a3c3ba 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 @@ -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() }) + } } } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt index 5ce01538c..5e22d4b30 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaNavigation.kt @@ -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" } /** diff --git a/core-common/src/main/java/com/google/samples/apps/nowinandroid/core/result/Result.kt b/core-common/src/main/java/com/google/samples/apps/nowinandroid/core/result/Result.kt new file mode 100644 index 000000000..d53d544bb --- /dev/null +++ b/core-common/src/main/java/com/google/samples/apps/nowinandroid/core/result/Result.kt @@ -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 { + data class Success(val data: T) : Result + data class Error(val exception: Throwable? = null) : Result + object Loading : Result +} + +fun Flow.asResult(): Flow> { + return this + .map> { + Result.Success(it) + } + .onStart { emit(Result.Loading) } +} diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index ad921b9b5..9d71a4f9b 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -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 + @Query(value = "SELECT * FROM topics") fun getTopicEntitiesStream(): Flow> diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt index 6b12a7a02..ed49f5105 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt @@ -42,6 +42,9 @@ class LocalTopicsRepository @Inject constructor( topicDao.getTopicEntitiesStream() .map { it.map(TopicEntity::asExternalModel) } + override fun getTopic(id: Int): Flow = + topicDao.getTopicEntity(id).map { it.asExternalModel() } + override suspend fun setFollowedTopicIds(followedTopicIds: Set) = niaPreferences.setFollowedTopicIds(followedTopicIds) diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt index f5da0ba85..6d61ab3b8 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt @@ -25,6 +25,11 @@ interface TopicsRepository { */ fun getTopicsStream(): Flow> + /** + * Gets data for a specific topic + */ + fun getTopic(id: Int): Flow + /** * Sets the user's currently followed topics */ diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt index 82d03ba25..1de555285 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt @@ -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 { + return getTopicsStream().map { it.first { topic -> topic.id == id } } + } + override suspend fun setFollowedTopicIds(followedTopicIds: Set) = niaPreferences.setFollowedTopicIds(followedTopicIds) diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt index 25fa03b88..1974f442f 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt @@ -58,7 +58,7 @@ class LocalTopicsRepositoryTest { subject = LocalTopicsRepository( topicDao = topicDao, network = network, - niaPreferences = niaPreferences, + niaPreferences = niaPreferences ) } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt index b9a17d923..87beaa4bb 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt @@ -40,6 +40,10 @@ class TestTopicDao : TopicDao { ) ) + override fun getTopicEntity(topicId: Int): Flow { + throw NotImplementedError("Unused in tests") + } + override fun getTopicEntitiesStream(): Flow> = entitiesStateFlow diff --git a/core-testing/build.gradle b/core-testing/build.gradle index acbc1da5e..3190597db 100644 --- a/core-testing/build.gradle +++ b/core-testing/build.gradle @@ -63,4 +63,4 @@ dependencies { force 'org.objenesis:objenesis:2.6' } } -} \ No newline at end of file +} diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt index 144b6a00c..7658a1928 100644 --- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt +++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt @@ -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> = topicsFlow + override fun getTopic(id: Int): Flow { + return topicsFlow.map { topics -> topics.find { it.id == id }!! } + } + override suspend fun setFollowedTopicIds(followedTopicIds: Set) { _followedTopicIds.tryEmit(followedTopicIds) } diff --git a/core-ui/src/main/res/values/strings.xml b/core-ui/src/main/res/values/strings.xml index b99d88d2c..46f144829 100644 --- a/core-ui/src/main/res/values/strings.xml +++ b/core-ui/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Bookmark Unbookmark + Back Open Resource Link diff --git a/feature-following/build.gradle b/feature-following/build.gradle index 05cb10c92..fde1a4bc9 100644 --- a/feature-following/build.gradle +++ b/feature-following/build.gradle @@ -71,4 +71,4 @@ dependencies { force 'org.objenesis:objenesis:2.6' } } -} \ No newline at end of file +} diff --git a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt index cfc412a28..3fa4e654c 100644 --- a/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt +++ b/feature-following/src/main/java/com/google/samples/apps/nowinandroid/feature/following/FollowingScreen.kt @@ -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 ) } diff --git a/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt b/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt index e0437111b..3d78105e5 100644 --- a/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt +++ b/feature-following/src/test/java/com/google/samples/apps/nowinandroid/following/FollowingViewModelTest.kt @@ -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() } diff --git a/feature-topic/.gitignore b/feature-topic/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature-topic/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-topic/build.gradle b/feature-topic/build.gradle new file mode 100644 index 000000000..0c3feac5e --- /dev/null +++ b/feature-topic/build.gradle @@ -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' + } + } +} diff --git a/feature-topic/src/androidTest/java/com/google/samples/apps/nowinandroid/following/TopicScreenTest.kt b/feature-topic/src/androidTest/java/com/google/samples/apps/nowinandroid/following/TopicScreenTest.kt new file mode 100644 index 000000000..e6b0d5b24 --- /dev/null +++ b/feature-topic/src/androidTest/java/com/google/samples/apps/nowinandroid/following/TopicScreenTest.kt @@ -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() + + 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! 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, + topics = listOf( + Topic( + id = 0, + name = "Headlines", + description = "" + ) + ), + authors = emptyList() + ) +) diff --git a/feature-topic/src/main/AndroidManifest.xml b/feature-topic/src/main/AndroidManifest.xml new file mode 100644 index 000000000..da59e9bf6 --- /dev/null +++ b/feature-topic/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/Navigation.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/Navigation.kt new file mode 100644 index 000000000..8d2a6b78e --- /dev/null +++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/Navigation.kt @@ -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" +} diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt new file mode 100644 index 000000000..9c8f08fca --- /dev/null +++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -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") + } + } + } +} diff --git a/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt new file mode 100644 index 000000000..9fa7d2b5b --- /dev/null +++ b/feature-topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -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>> = + topicsRepository.getFollowedTopicIdsStream().asResult() + + // Observe topic information + private val topic: Flow> = topicsRepository.getTopic(topicId).asResult() + + // Observe the News for this topic + private val newsStream: Flow>> = + newsRepository.getNewsResourcesStream(setOf(topicId)).asResult() + + val uiState: StateFlow = + 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) : NewsUiState + object Error : NewsUiState + object Loading : NewsUiState +} + +data class TopicScreenUiState( + val topicState: TopicUiState, + val newsState: NewsUiState +) diff --git a/feature-topic/src/main/res/values/strings.xml b/feature-topic/src/main/res/values/strings.xml new file mode 100644 index 000000000..eeeb01a7d --- /dev/null +++ b/feature-topic/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + Topic + Loading topic + diff --git a/feature-topic/src/test/java/com/google/samples/apps/nowinandroid/following/TopicViewModelTest.kt b/feature-topic/src/test/java/com/google/samples/apps/nowinandroid/following/TopicViewModelTest.kt new file mode 100644 index 000000000..0c87161f4 --- /dev/null +++ b/feature-topic/src/test/java/com/google/samples/apps/nowinandroid/following/TopicViewModelTest.kt @@ -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! 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, + topics = listOf( + Topic( + id = 0, + name = "Headlines", + shortDescription = "", + longDescription = "long description", + url = "URL", + imageUrl = "image URL", + ) + ), + authors = emptyList() + ) +) diff --git a/settings.gradle b/settings.gradle index a76543a9f..c7d7c0101 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,4 +46,5 @@ include ':core-ui' include ':core-testing' include ':feature-following' include ':feature-foryou' +include ':feature-topic' include ':sync'