diff --git a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt index 0c6d06a5c..372aff8b5 100644 --- a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt +++ b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt @@ -1,6 +1,6 @@ -androidx.activity:activity-compose:1.9.2 -androidx.activity:activity-ktx:1.9.2 -androidx.activity:activity:1.9.2 +androidx.activity:activity-compose:1.9.3 +androidx.activity:activity-ktx:1.9.3 +androidx.activity:activity:1.9.3 androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-jvm:1.8.0 androidx.annotation:annotation:1.8.0 diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt index f24df5d3d..c7f8c0ff6 100644 --- a/app/dependencies/prodReleaseRuntimeClasspath.txt +++ b/app/dependencies/prodReleaseRuntimeClasspath.txt @@ -1,6 +1,6 @@ -androidx.activity:activity-compose:1.9.2 -androidx.activity:activity-ktx:1.9.2 -androidx.activity:activity:1.9.2 +androidx.activity:activity-compose:1.9.3 +androidx.activity:activity-ktx:1.9.3 +androidx.activity:activity:1.9.3 androidx.annotation:annotation-experimental:1.4.1 androidx.annotation:annotation-jvm:1.8.1 androidx.annotation:annotation:1.8.1 @@ -196,11 +196,11 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava com.google.j2objc:j2objc-annotations:1.3 com.google.protobuf:protobuf-javalite:4.26.1 com.google.protobuf:protobuf-kotlin-lite:4.26.1 -com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0 com.squareup.okhttp3:logging-interceptor:4.12.0 com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 +com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0 com.squareup.retrofit2:retrofit:2.11.0 io.coil-kt:coil-base:2.7.0 io.coil-kt:coil-compose-base:2.7.0 diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index f421adaeb..54053a1bb 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -16,13 +16,16 @@ package com.google.samples.apps.nowinandroid.ui +import androidx.compose.ui.semantics.SemanticsActions.ScrollBy import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -32,6 +35,7 @@ import androidx.test.espresso.Espresso import androidx.test.espresso.NoActivityResumedException import com.google.samples.apps.nowinandroid.MainActivity import com.google.samples.apps.nowinandroid.R +import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule @@ -75,6 +79,9 @@ class NavigationTest { @Inject lateinit var topicsRepository: TopicsRepository + @Inject + lateinit var newsRepository: NewsRepository + // The strings used for matching in these tests private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up) private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title) @@ -267,4 +274,44 @@ class NavigationTest { onNodeWithTag("topic:${topic.id}").assertExists() } } + + @Test + fun navigatingToTopicFromForYou_showsTopicDetails() { + composeTestRule.apply { + // Get the first news resource + val newsResource = runBlocking { + newsRepository.getNewsResources().first().first() + } + + // Get its first topic and follow it + val topic = newsResource.topics.first() + onNodeWithText(topic.name).performClick() + + // Get the news feed and scroll to the news resource + // Note: Possible flakiness. If the content of the news resource is long then the topic + // tag might not be visible meaning it cannot be clicked + onNodeWithTag("forYou:feed") + .performScrollToNode(hasTestTag("newsResourceCard:${newsResource.id}")) + .fetchSemanticsNode() + .apply { + val newsResourceCardNode = onNodeWithTag("newsResourceCard:${newsResource.id}") + .fetchSemanticsNode() + config[ScrollBy].action?.invoke( + 0f, + // to ensure the bottom of the card is visible, + // manually scroll the difference between the height of + // the scrolling node and the height of the card + (newsResourceCardNode.size.height - size.height).coerceAtLeast(0).toFloat(), + ) + } + + // Click the first topic tag + onAllNodesWithTag("topicTag:${topic.id}", useUnmergedTree = true) + .onFirst() + .performClick() + + // Verify that we're on the correct topic details screen + onNodeWithTag("topic:${topic.id}").assertExists() + } + } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index f878c003b..e079c98f4 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -20,10 +20,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen +import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute +import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen +import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic +import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.ui.NiaAppState import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen @@ -44,10 +46,18 @@ fun NiaNavHost( val navController = appState.navController NavHost( navController = navController, - startDestination = ForYouRoute, + startDestination = ForYouBaseRoute, modifier = modifier, ) { - forYouScreen(onTopicClick = navController::navigateToInterests) + forYouSection( + onTopicClick = navController::navigateToTopic, + ) { + topicScreen( + showBackButton = true, + onBackClick = navController::popBackStack, + onTopicClick = navController::navigateToTopic, + ) + } bookmarksScreen( onTopicClick = navController::navigateToInterests, onShowSnackbar = onShowSnackbar, diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt index 815061273..429e626ff 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksRoute +import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute import kotlin.reflect.KClass @@ -29,9 +30,18 @@ import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR import com.google.samples.apps.nowinandroid.feature.search.R as searchR /** - * Type for the top level destinations in the application. Each of these destinations - * can contain one or more screens (based on the window size). Navigation from one screen to the - * next within a single destination will be handled directly in composables. + * Type for the top level destinations in the application. Contains metadata about the destination + * that is used in the top app bar and common navigation UI. + * + * @param selectedIcon The icon to be displayed in the navigation UI when this destination is + * selected. + * @param unselectedIcon The icon to be displayed in the navigation UI when this destination is + * not selected. + * @param iconTextId Text that to be displayed in the navigation UI. + * @param titleTextId Text that is displayed on the top app bar. + * @param route The route to use when navigating to this destination. + * @param baseRoute The highest ancestor of this destination. Defaults to [route], meaning that + * there is a single destination in that section of the app (no nested destinations). */ enum class TopLevelDestination( val selectedIcon: ImageVector, @@ -39,6 +49,7 @@ enum class TopLevelDestination( @StringRes val iconTextId: Int, @StringRes val titleTextId: Int, val route: KClass<*>, + val baseRoute: KClass<*> = route, ) { FOR_YOU( selectedIcon = NiaIcons.Upcoming, @@ -46,6 +57,7 @@ enum class TopLevelDestination( iconTextId = forYouR.string.feature_foryou_title, titleTextId = R.string.app_name, route = ForYouRoute::class, + baseRoute = ForYouBaseRoute::class, ), BOOKMARKS( selectedIcon = NiaIcons.Bookmarks, diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 6cdc32bb0..640b22e83 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -152,7 +152,7 @@ internal fun NiaApp( appState.topLevelDestinations.forEach { destination -> val hasUnread = unreadDestinations.contains(destination) val selected = currentDestination - .isRouteInHierarchy(destination.route) + .isRouteInHierarchy(destination.baseRoute) item( selected = selected, onClick = { appState.navigateToTopLevelDestination(destination) }, diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 75a294c01..249f07590 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -90,7 +90,7 @@ class NiaAppState( val currentTopLevelDestination: TopLevelDestination? @Composable get() { return TopLevelDestination.entries.firstOrNull { topLevelDestination -> - currentDestination?.hasRoute(route = topLevelDestination.route) ?: false + currentDestination?.hasRoute(route = topLevelDestination.route) == true } } diff --git a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png index e052b5920..30873b584 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png index 53bf6f3c5..e2dffaf01 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png b/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png index 3d2c79256..253b6be4c 100644 Binary files a/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png and b/app/src/testDemo/screenshots/insets_snackbar_expanded_expanded.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png index 3e38938d6..79f808f44 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/snackbar_expanded_expanded.png b/app/src/testDemo/screenshots/snackbar_expanded_expanded.png index 4997a83af..841a02eaf 100644 Binary files a/app/src/testDemo/screenshots/snackbar_expanded_expanded.png and b/app/src/testDemo/screenshots/snackbar_expanded_expanded.png differ diff --git a/app/src/testDemo/screenshots/snackbar_medium_medium.png b/app/src/testDemo/screenshots/snackbar_medium_medium.png index 36fffa9c6..2800575b8 100644 Binary files a/app/src/testDemo/screenshots/snackbar_medium_medium.png and b/app/src/testDemo/screenshots/snackbar_medium_medium.png differ diff --git a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt index 0cdec6090..da90eae61 100644 --- a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/FakeNewsRepository.kt @@ -17,16 +17,13 @@ package com.google.samples.apps.nowinandroid.core.data.test.repository import com.google.samples.apps.nowinandroid.core.data.Synchronizer -import com.google.samples.apps.nowinandroid.core.data.model.asEntity +import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity -import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource -import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -48,9 +45,11 @@ class FakeNewsRepository @Inject constructor( query: NewsResourceQuery, ): Flow> = flow { + val newsResources = datasource.getNewsResources() + val topics = datasource.getTopics() + emit( - datasource - .getNewsResources() + newsResources .filter { networkNewsResource -> // Filter out any news resources which don't match the current query. // If no query parameters (filterTopicIds or filterNewsIds) are specified @@ -64,8 +63,7 @@ class FakeNewsRepository @Inject constructor( ) .all(true::equals) } - .map(NetworkNewsResource::asEntity) - .map(NewsResourceEntity::asExternalModel), + .map { it.asExternalModel(topics) }, ) }.flowOn(ioDispatcher) diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NewsResource.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NewsResource.kt index c3ad91dfe..01d0905f0 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NewsResource.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NewsResource.kt @@ -19,8 +19,10 @@ package com.google.samples.apps.nowinandroid.core.data.model import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource -import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded +import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic +import com.google.samples.apps.nowinandroid.core.network.model.asExternalModel fun NetworkNewsResource.asEntity() = NewsResourceEntity( id = id, @@ -32,16 +34,6 @@ fun NetworkNewsResource.asEntity() = NewsResourceEntity( type = type, ) -fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity( - id = id, - title = title, - content = content, - url = url, - headerImageUrl = headerImageUrl, - publishDate = publishDate, - type = type, -) - /** * A shell [TopicEntity] to fulfill the foreign key constraint when inserting * a [NewsResourceEntity] into the DB @@ -65,3 +57,17 @@ fun NetworkNewsResource.topicCrossReferences(): List topicId = topicId, ) } + +fun NetworkNewsResource.asExternalModel(topics: List) = + NewsResource( + id = id, + title = title, + content = content, + url = url, + headerImageUrl = headerImageUrl, + publishDate = publishDate, + type = type, + topics = topics + .filter { networkTopic -> this.topics.contains(networkTopic.id) } + .map(NetworkTopic::asExternalModel), + ) diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NetworkEntityKtTest.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NetworkEntityKtTest.kt deleted file mode 100644 index 7dd251a99..000000000 --- a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NetworkEntityKtTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.core.data.model - -import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource -import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded -import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic -import kotlinx.datetime.Instant -import org.junit.Test -import kotlin.test.assertEquals - -class NetworkEntityKtTest { - - @Test - fun network_topic_can_be_mapped_to_topic_entity() { - val networkModel = NetworkTopic( - id = "0", - name = "Test", - shortDescription = "short description", - longDescription = "long description", - url = "URL", - imageUrl = "image URL", - ) - val entity = networkModel.asEntity() - - assertEquals("0", entity.id) - assertEquals("Test", entity.name) - assertEquals("short description", entity.shortDescription) - assertEquals("long description", entity.longDescription) - assertEquals("URL", entity.url) - assertEquals("image URL", entity.imageUrl) - } - - @Test - fun network_news_resource_can_be_mapped_to_news_resource_entity() { - val networkModel = - NetworkNewsResource( - id = "0", - title = "title", - content = "content", - url = "url", - headerImageUrl = "headerImageUrl", - publishDate = Instant.fromEpochMilliseconds(1), - type = "Article 📚", - ) - val entity = networkModel.asEntity() - - assertEquals("0", entity.id) - assertEquals("title", entity.title) - assertEquals("content", entity.content) - assertEquals("url", entity.url) - assertEquals("headerImageUrl", entity.headerImageUrl) - assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate) - assertEquals("Article 📚", entity.type) - - val expandedNetworkModel = - NetworkNewsResourceExpanded( - id = "0", - title = "title", - content = "content", - url = "url", - headerImageUrl = "headerImageUrl", - publishDate = Instant.fromEpochMilliseconds(1), - type = "Article 📚", - ) - - val entityFromExpanded = expandedNetworkModel.asEntity() - - assertEquals("0", entityFromExpanded.id) - assertEquals("title", entityFromExpanded.title) - assertEquals("content", entityFromExpanded.content) - assertEquals("url", entityFromExpanded.url) - assertEquals("headerImageUrl", entityFromExpanded.headerImageUrl) - assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate) - assertEquals("Article 📚", entityFromExpanded.type) - } -} diff --git a/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NetworkEntityTest.kt b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NetworkEntityTest.kt new file mode 100644 index 000000000..52dbe5117 --- /dev/null +++ b/core/data/src/test/kotlin/com/google/samples/apps/nowinandroid/core/data/model/NetworkEntityTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.model + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic +import com.google.samples.apps.nowinandroid.core.network.model.asExternalModel +import kotlinx.datetime.Instant +import org.junit.Test +import kotlin.test.assertEquals + +class NetworkEntityTest { + + @Test + fun networkTopicMapsToDatabaseModel() { + val networkModel = NetworkTopic( + id = "0", + name = "Test", + shortDescription = "short description", + longDescription = "long description", + url = "URL", + imageUrl = "image URL", + ) + val entity = networkModel.asEntity() + + assertEquals("0", entity.id) + assertEquals("Test", entity.name) + assertEquals("short description", entity.shortDescription) + assertEquals("long description", entity.longDescription) + assertEquals("URL", entity.url) + assertEquals("image URL", entity.imageUrl) + } + + @Test + fun networkNewsResourceMapsToDatabaseModel() { + val networkModel = + NetworkNewsResource( + id = "0", + title = "title", + content = "content", + url = "url", + headerImageUrl = "headerImageUrl", + publishDate = Instant.fromEpochMilliseconds(1), + type = "Article 📚", + ) + val entity = networkModel.asEntity() + + assertEquals("0", entity.id) + assertEquals("title", entity.title) + assertEquals("content", entity.content) + assertEquals("url", entity.url) + assertEquals("headerImageUrl", entity.headerImageUrl) + assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate) + assertEquals("Article 📚", entity.type) + } + + @Test + fun networkTopicMapsToExternalModel() { + val networkTopic = NetworkTopic( + id = "0", + name = "Test", + shortDescription = "short description", + longDescription = "long description", + url = "URL", + imageUrl = "imageUrl", + ) + + val expected = Topic( + id = "0", + name = "Test", + shortDescription = "short description", + longDescription = "long description", + url = "URL", + imageUrl = "imageUrl", + ) + + assertEquals(expected, networkTopic.asExternalModel()) + } + + @Test + fun networkNewsResourceMapsToExternalModel() { + val networkNewsResource = NetworkNewsResource( + id = "0", + title = "title", + content = "content", + url = "url", + headerImageUrl = "headerImageUrl", + publishDate = Instant.fromEpochMilliseconds(1), + type = "Article 📚", + topics = listOf("1", "2"), + ) + + val networkTopics = listOf( + NetworkTopic( + id = "1", + name = "Test 1", + shortDescription = "short description 1", + longDescription = "long description 1", + url = "url 1", + imageUrl = "imageUrl 1", + ), + NetworkTopic( + id = "2", + name = "Test 2", + shortDescription = "short description 2", + longDescription = "long description 2", + url = "url 2", + imageUrl = "imageUrl 2", + ), + ) + + val expected = NewsResource( + id = "0", + title = "title", + content = "content", + url = "url", + headerImageUrl = "headerImageUrl", + publishDate = Instant.fromEpochMilliseconds(1), + type = "Article 📚", + topics = networkTopics.map(NetworkTopic::asExternalModel), + ) + assertEquals(expected, networkNewsResource.asExternalModel(networkTopics)) + } +} diff --git a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt index 89af19c99..7b66af796 100644 --- a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt +++ b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt @@ -34,18 +34,3 @@ data class NetworkNewsResource( val type: String, val topics: List = listOf(), ) - -/** - * Network representation of [NewsResource] when fetched from /newsresources/{id} - */ -@Serializable -data class NetworkNewsResourceExpanded( - val id: String, - val title: String, - val content: String, - val url: String, - val headerImageUrl: String, - val publishDate: Instant, - val type: String, - val topics: List = listOf(), -) diff --git a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkTopic.kt b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkTopic.kt index e1043938f..0d21c09e7 100644 --- a/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkTopic.kt +++ b/core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/model/NetworkTopic.kt @@ -32,3 +32,13 @@ data class NetworkTopic( val imageUrl: String = "", val followed: Boolean = false, ) + +fun NetworkTopic.asExternalModel(): Topic = + Topic( + id = id, + name = name, + shortDescription = shortDescription, + longDescription = longDescription, + url = url, + imageUrl = imageUrl, + ) diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index 7c41d74d0..2395eb156 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -116,9 +117,11 @@ fun NewsResourceCardExpanded( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), // Use custom label for accessibility services to communicate button's action to user. // Pass null for action to only override the label and not the actual action. - modifier = modifier.semantics { - onClick(label = clickActionLabel, action = null) - }, + modifier = modifier + .semantics { + onClick(label = clickActionLabel, action = null) + } + .testTag("newsResourceCard:${userNewsResource.id}"), ) { Column { if (!userNewsResource.headerImageUrl.isNullOrEmpty()) { @@ -336,9 +339,11 @@ fun NewsResourceTopics( } Text( text = followableTopic.topic.name.uppercase(Locale.getDefault()), - modifier = Modifier.semantics { - this.contentDescription = contentDescription - }, + modifier = Modifier + .semantics { + this.contentDescription = contentDescription + } + .testTag("topicTag:${followableTopic.topic.id}"), ) }, ) diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt index 9d98f1618..b77ce72a0 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt +++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt @@ -20,30 +20,46 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation import androidx.navigation.navDeepLink import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_URI_PATTERN import com.google.samples.apps.nowinandroid.feature.foryou.ForYouScreen import kotlinx.serialization.Serializable -@Serializable data object ForYouRoute +@Serializable data object ForYouRoute // route to ForYou screen + +@Serializable data object ForYouBaseRoute // route to base navigation graph fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute, navOptions) -fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) { - composable( - deepLinks = listOf( - navDeepLink { - /** - * This destination has a deep link that enables a specific news resource to be - * opened from a notification (@see SystemTrayNotifier for more). The news resource - * ID is sent in the URI rather than being modelled in the route type because it's - * transient data (stored in SavedStateHandle) that is cleared after the user has - * opened the news resource. - */ - uriPattern = DEEP_LINK_URI_PATTERN - }, - ), - ) { - ForYouScreen(onTopicClick) +/** + * The ForYou section of the app. It can also display information about topics. + * This should be supplied from a separate module. + * + * @param onTopicClick - Called when a topic is clicked, contains the ID of the topic + * @param topicDestination - Destination for topic content + */ +fun NavGraphBuilder.forYouSection( + onTopicClick: (String) -> Unit, + topicDestination: NavGraphBuilder.() -> Unit, +) { + navigation(startDestination = ForYouRoute) { + composable( + deepLinks = listOf( + navDeepLink { + /** + * This destination has a deep link that enables a specific news resource to be + * opened from a notification (@see SystemTrayNotifier for more). The news resource + * ID is sent in the URI rather than being modelled in the route type because it's + * transient data (stored in SavedStateHandle) that is cleared after the user has + * opened the news resource. + */ + uriPattern = DEEP_LINK_URI_PATTERN + }, + ), + ) { + ForYouScreen(onTopicClick) + } + topicDestination() } } diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index a18d9988a..8ef0d786d 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -71,7 +71,7 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems import com.google.samples.apps.nowinandroid.feature.topic.R.string @Composable -internal fun TopicScreen( +fun TopicScreen( showBackButton: Boolean, onBackClick: () -> Unit, onTopicClick: (String) -> Unit, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8fe6368e..b1d9232ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,8 +3,8 @@ accompanist = "0.34.0" androidDesugarJdkLibs = "2.0.4" # AGP and tools should be updated together androidGradlePlugin = "8.6.1" -androidTools = "31.6.1" -androidxActivity = "1.9.2" +androidTools = "31.7.2" +androidxActivity = "1.9.3" androidxAppCompat = "1.7.0" androidxBrowser = "1.8.0" androidxComposeBom = "2024.09.00" @@ -12,14 +12,14 @@ androidxComposeRuntimeTracing = "1.0.0-beta01" androidxCore = "1.13.1" androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.1.1" -androidxEspresso = "3.5.1" +androidxEspresso = "3.6.1" androidxHiltNavigationCompose = "1.2.0" androidxLifecycle = "2.8.6" androidxMacroBenchmark = "1.3.0" androidxMetrics = "1.0.0-beta01" androidxNavigation = "2.8.0" androidxProfileinstaller = "1.3.1" -androidxTestCore = "1.5.0" +androidxTestCore = "1.6.1" androidxTestExt = "1.2.1" androidxTestRules = "1.6.1" androidxTestRunner = "1.6.2"