2 pane support in Interests screen (#1234)

* Add dependency on material3 adaptive

Change-Id: Ic49934112a4bdbf15a68c694fbc6b0f23de960a6

* Add InterestsListDetailScreen composable

Change-Id: I27e1f6d2e0eeac781baf2b671fa51a864ea5a971

* Store selectedTopicId in InterestsViewModel

Change-Id: Id93704335686f171fbf80bdb54865d0f32dc36ce

* Pass detail pane composable down

Change-Id: I82752d8cfbb3519395f37748fb5f64b769c0c293

* Navigate to initial topic if provided

Change-Id: I8998a55a29cdaf90577fa730d55c4ac2f54d6e5b

* Lift LDPS up to app module

Change-Id: Ibc6e8e598cd0cb62f804f11b2e48d8ae3a81df85

* Fix some navigation behavior

Change-Id: Ib6c16aff692b9ce997747a30f2863303cc82fd8b

* Navigate to initial topic if provided

Change-Id: Iaafe4f876655d51243d7b99be985e9440fe2d4ed

* Remove dependency in interests feature module

Change-Id: Id517c95e11f93e1c7e17d749a7af0cfdf6085a1f

* Hide back arrow when the topics list is visible

Change-Id: I8901c3f79b11d35568f0ae779f97fab90e574aa8

* Update interests tests

Change-Id: Ie5daf55985fdb53570397cb652abe31bad78f5cd

* Highlight selected topic when displaying 2 panes

Change-Id: Ifef9fb599f828f58390374b11eacc8be6c280415

* update dependency baselines

Change-Id: I90dc21df3337865f4c5368634d3d45fcb0eccc00

* run spotless apply

Change-Id: Ib5fb1b7fc26a62bd5e271c2a3721f1c13173f7f8

* Convert isListPaneHidden to isListPaneVisible

Change-Id: I6e54f710df7db5ed6f3ec1cb284bc29f2763a657

* Set semantics for selected state

Change-Id: I31f27d5036d07c9607909c09ac52a72391f899ca

* Use scaffold roles when determining visibility

Change-Id: Ib5fe236f182a5eeab20b61692a1cd53c17b68648

* Update multipleBackStackInterests test

Change-Id: I1e372f7989817151a6765205291b13b561187fa8
pull/1259/head
Jonathan Koren 4 months ago committed by GitHub
parent abe798056e
commit 19f6f9e09a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -12,41 +12,41 @@ androidx.browser:browser:1.6.0
androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0
androidx.compose.animation:animation-android:1.6.1
androidx.compose.animation:animation-core-android:1.6.1
androidx.compose.animation:animation-core:1.6.1
androidx.compose.animation:animation:1.6.1
androidx.compose.foundation:foundation-android:1.6.1
androidx.compose.foundation:foundation-layout-android:1.6.1
androidx.compose.foundation:foundation-layout:1.6.1
androidx.compose.foundation:foundation:1.6.1
androidx.compose.animation:animation-android:1.6.2
androidx.compose.animation:animation-core-android:1.6.2
androidx.compose.animation:animation-core:1.6.2
androidx.compose.animation:animation:1.6.2
androidx.compose.foundation:foundation-android:1.6.2
androidx.compose.foundation:foundation-layout-android:1.6.2
androidx.compose.foundation:foundation-layout:1.6.2
androidx.compose.foundation:foundation:1.6.2
androidx.compose.material3:material3-android:1.2.0
androidx.compose.material3:material3:1.2.0
androidx.compose.material:material-icons-core-android:1.6.1
androidx.compose.material:material-icons-core:1.6.1
androidx.compose.material:material-icons-extended-android:1.6.1
androidx.compose.material:material-icons-extended:1.6.1
androidx.compose.material:material-ripple-android:1.6.1
androidx.compose.material:material-ripple:1.6.1
androidx.compose.runtime:runtime-android:1.6.1
androidx.compose.runtime:runtime-saveable-android:1.6.1
androidx.compose.runtime:runtime-saveable:1.6.1
androidx.compose.runtime:runtime:1.6.1
androidx.compose.ui:ui-android:1.6.1
androidx.compose.ui:ui-geometry-android:1.6.1
androidx.compose.ui:ui-geometry:1.6.1
androidx.compose.ui:ui-graphics-android:1.6.1
androidx.compose.ui:ui-graphics:1.6.1
androidx.compose.ui:ui-text-android:1.6.1
androidx.compose.ui:ui-text:1.6.1
androidx.compose.ui:ui-tooling-preview-android:1.6.1
androidx.compose.ui:ui-tooling-preview:1.6.1
androidx.compose.ui:ui-unit-android:1.6.1
androidx.compose.ui:ui-unit:1.6.1
androidx.compose.ui:ui-util-android:1.6.1
androidx.compose.ui:ui-util:1.6.1
androidx.compose.ui:ui:1.6.1
androidx.compose:compose-bom:2024.02.00
androidx.compose.material:material-icons-core-android:1.6.2
androidx.compose.material:material-icons-core:1.6.2
androidx.compose.material:material-icons-extended-android:1.6.2
androidx.compose.material:material-icons-extended:1.6.2
androidx.compose.material:material-ripple-android:1.6.2
androidx.compose.material:material-ripple:1.6.2
androidx.compose.runtime:runtime-android:1.6.2
androidx.compose.runtime:runtime-saveable-android:1.6.2
androidx.compose.runtime:runtime-saveable:1.6.2
androidx.compose.runtime:runtime:1.6.2
androidx.compose.ui:ui-android:1.6.2
androidx.compose.ui:ui-geometry-android:1.6.2
androidx.compose.ui:ui-geometry:1.6.2
androidx.compose.ui:ui-graphics-android:1.6.2
androidx.compose.ui:ui-graphics:1.6.2
androidx.compose.ui:ui-text-android:1.6.2
androidx.compose.ui:ui-text:1.6.2
androidx.compose.ui:ui-tooling-preview-android:1.6.2
androidx.compose.ui:ui-tooling-preview:1.6.2
androidx.compose.ui:ui-unit-android:1.6.2
androidx.compose.ui:ui-unit:1.6.2
androidx.compose.ui:ui-util-android:1.6.2
androidx.compose.ui:ui-util:1.6.2
androidx.compose.ui:ui:1.6.2
androidx.compose:compose-bom:2024.02.01
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core:1.12.0

@ -89,17 +89,21 @@ dependencies {
implementation(projects.sync.work)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing.ktx)
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt)
ksp(libs.hilt.compiler)
debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(projects.uiTestHiltManifest)

@ -13,44 +13,46 @@ androidx.browser:browser:1.6.0
androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0
androidx.compose.animation:animation-android:1.6.1
androidx.compose.animation:animation-core-android:1.6.1
androidx.compose.animation:animation-core:1.6.1
androidx.compose.animation:animation:1.6.1
androidx.compose.foundation:foundation-android:1.6.1
androidx.compose.foundation:foundation-layout-android:1.6.1
androidx.compose.foundation:foundation-layout:1.6.1
androidx.compose.foundation:foundation:1.6.1
androidx.compose.animation:animation-android:1.6.2
androidx.compose.animation:animation-core-android:1.6.2
androidx.compose.animation:animation-core:1.6.2
androidx.compose.animation:animation:1.6.2
androidx.compose.foundation:foundation-android:1.6.2
androidx.compose.foundation:foundation-layout-android:1.6.2
androidx.compose.foundation:foundation-layout:1.6.2
androidx.compose.foundation:foundation:1.6.2
androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06
androidx.compose.material3:material3-adaptive:1.0.0-alpha06
androidx.compose.material3:material3-android:1.2.0
androidx.compose.material3:material3-window-size-class-android:1.2.0
androidx.compose.material3:material3-window-size-class:1.2.0
androidx.compose.material3:material3:1.2.0
androidx.compose.material:material-icons-core-android:1.6.1
androidx.compose.material:material-icons-core:1.6.1
androidx.compose.material:material-icons-extended-android:1.6.1
androidx.compose.material:material-icons-extended:1.6.1
androidx.compose.material:material-ripple-android:1.6.1
androidx.compose.material:material-ripple:1.6.1
androidx.compose.runtime:runtime-android:1.6.1
androidx.compose.runtime:runtime-saveable-android:1.6.1
androidx.compose.runtime:runtime-saveable:1.6.1
androidx.compose.material:material-icons-core-android:1.6.2
androidx.compose.material:material-icons-core:1.6.2
androidx.compose.material:material-icons-extended-android:1.6.2
androidx.compose.material:material-icons-extended:1.6.2
androidx.compose.material:material-ripple-android:1.6.2
androidx.compose.material:material-ripple:1.6.2
androidx.compose.runtime:runtime-android:1.6.2
androidx.compose.runtime:runtime-saveable-android:1.6.2
androidx.compose.runtime:runtime-saveable:1.6.2
androidx.compose.runtime:runtime-tracing:1.0.0-beta01
androidx.compose.runtime:runtime:1.6.1
androidx.compose.ui:ui-android:1.6.1
androidx.compose.ui:ui-geometry-android:1.6.1
androidx.compose.ui:ui-geometry:1.6.1
androidx.compose.ui:ui-graphics-android:1.6.1
androidx.compose.ui:ui-graphics:1.6.1
androidx.compose.ui:ui-text-android:1.6.1
androidx.compose.ui:ui-text:1.6.1
androidx.compose.ui:ui-tooling-preview-android:1.6.1
androidx.compose.ui:ui-tooling-preview:1.6.1
androidx.compose.ui:ui-unit-android:1.6.1
androidx.compose.ui:ui-unit:1.6.1
androidx.compose.ui:ui-util-android:1.6.1
androidx.compose.ui:ui-util:1.6.1
androidx.compose.ui:ui:1.6.1
androidx.compose:compose-bom:2024.02.00
androidx.compose.runtime:runtime:1.6.2
androidx.compose.ui:ui-android:1.6.2
androidx.compose.ui:ui-geometry-android:1.6.2
androidx.compose.ui:ui-geometry:1.6.2
androidx.compose.ui:ui-graphics-android:1.6.2
androidx.compose.ui:ui-graphics:1.6.2
androidx.compose.ui:ui-text-android:1.6.2
androidx.compose.ui:ui-text:1.6.2
androidx.compose.ui:ui-tooling-preview-android:1.6.2
androidx.compose.ui:ui-tooling-preview:1.6.2
androidx.compose.ui:ui-unit-android:1.6.2
androidx.compose.ui:ui-unit:1.6.2
androidx.compose.ui:ui-util-android:1.6.2
androidx.compose.ui:ui-util:1.6.2
androidx.compose.ui:ui:1.6.2
androidx.compose:compose-bom:2024.02.01
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core-splashscreen:1.0.1
@ -116,7 +118,8 @@ androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window:window:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window:1.2.0
androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4

@ -274,10 +274,10 @@ class NavigationTest {
// Select the last topic
val topic = runBlocking {
topicsRepository.getTopics().first().sortedBy(Topic::name).last().name
topicsRepository.getTopics().first().sortedBy(Topic::name).last()
}
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic))
onNodeWithText(topic).performClick()
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name))
onNodeWithText(topic.name).performClick()
// Switch tab
onNodeWithText(forYou).performClick()
@ -285,8 +285,8 @@ class NavigationTest {
// Come back to Interests
onNodeWithText(interests).performClick()
// Verify we're not in the list of interests
onNodeWithTag("interests:topics").assertDoesNotExist()
// Verify the topic is still shown
onNodeWithTag("topic:${topic.id}").assertExists()
}
}
}

@ -22,12 +22,11 @@ import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
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
/**
* Top-level navigation graph. Navigation is organized as explained at
@ -49,24 +48,16 @@ fun NiaNavHost(
startDestination = startDestination,
modifier = modifier,
) {
forYouScreen(onTopicClick = navController::navigateToTopic)
forYouScreen(onTopicClick = navController::navigateToInterests)
bookmarksScreen(
onTopicClick = navController::navigateToTopic,
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToTopic,
)
interestsGraph(
onTopicClick = navController::navigateToTopic,
nestedGraphs = {
topicScreen(
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
},
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}

@ -39,7 +39,7 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigat
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
@ -173,7 +173,7 @@ class NiaAppState(
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions)
INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions)
}
}
}

@ -0,0 +1,35 @@
/*
* Copyright 2024 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.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null)
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId
}
}

@ -0,0 +1,136 @@
/*
* Copyright 2024 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.ui.interests2pane
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.PaneAdaptedValue
import androidx.compose.material3.adaptive.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route"
fun NavGraphBuilder.interestsListDetailScreen() {
composable(
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
type = NavType.StringType
defaultValue = null
nullable = true
},
),
) {
InterestsListDetailScreen()
}
}
@Composable
internal fun InterestsListDetailScreen(
viewModel: Interests2PaneViewModel = hiltViewModel(),
) {
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
InterestsListDetailScreen(
selectedTopicId = selectedTopicId,
onTopicClick = viewModel::onTopicClick,
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
internal fun InterestsListDetailScreen(
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
) {
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator<Nothing>()
BackHandler(listDetailNavigator.canNavigateBack()) {
listDetailNavigator.navigateBack()
}
val nestedNavController = rememberNavController()
fun onTopicClickShowDetailPane(topicId: String) {
onTopicClick(topicId)
nestedNavController.navigateToTopic(topicId) {
popUpTo(DETAIL_PANE_NAVHOST_ROUTE)
}
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
ListDetailPaneScaffold(
scaffoldState = listDetailNavigator.scaffoldState,
listPane = {
InterestsRoute(
onTopicClick = ::onTopicClickShowDetailPane,
highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
)
},
detailPane = {
NavHost(
navController = nestedNavController,
startDestination = TOPIC_ROUTE,
route = DETAIL_PANE_NAVHOST_ROUTE,
) {
topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane,
)
composable(route = TOPIC_ROUTE) {
Box {
Text("Placeholder")
}
}
}
},
)
LaunchedEffect(Unit) {
if (selectedTopicId != null) {
// Initial topic ID was provided when navigating to Interests, so show its details.
onTopicClickShowDetailPane(selectedTopicId)
}
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
scaffoldState.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
scaffoldState.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded

@ -30,4 +30,4 @@ dependencies {
testImplementation(projects.core.testing)
androidTestImplementation(projects.core.testing)
}
}

@ -74,7 +74,10 @@ class InterestsScreenTest {
fun interestsWithTopics_whenTopicsFollowed_showFollowedAndUnfollowedTopicsWithInfo() {
composeTestRule.setContent {
InterestsScreen(
uiState = InterestsUiState.Interests(topics = followableTopicTestData),
uiState = InterestsUiState.Interests(
topics = followableTopicTestData,
selectedTopicId = null,
),
)
}

@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -49,6 +50,7 @@ fun InterestsItem(
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
description: String = "",
isSelected: Boolean = false,
) {
ListItem(
leadingContent = {
@ -83,10 +85,16 @@ fun InterestsItem(
)
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
containerColor = if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant
} else {
Color.Transparent
},
),
modifier = modifier
.semantics(mergeDescendants = true) { /* no-op */ }
.semantics(mergeDescendants = true) {
selected = isSelected
}
.clickable(enabled = true, onClick = onClick),
)
}
@ -179,3 +187,21 @@ private fun InterestsCardWithEmptyDescriptionPreview() {
}
}
}
@Preview
@Composable
private fun InterestsCardSelectedPreview() {
NiaTheme {
Surface {
InterestsItem(
name = "Compose",
description = "",
following = true,
topicImageUrl = "",
onClick = { },
onFollowButtonClick = { },
isSelected = true,
)
}
}
}

@ -35,9 +35,10 @@ import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParame
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
@Composable
internal fun InterestsRoute(
fun InterestsRoute(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
highlightSelectedTopic: Boolean = false,
viewModel: InterestsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -45,7 +46,11 @@ internal fun InterestsRoute(
InterestsScreen(
uiState = uiState,
followTopic = viewModel::followTopic,
onTopicClick = onTopicClick,
onTopicClick = {
viewModel.onTopicClick(it)
onTopicClick(it)
},
highlightSelectedTopic = highlightSelectedTopic,
modifier = modifier,
)
}
@ -56,6 +61,7 @@ internal fun InterestsScreen(
followTopic: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
highlightSelectedTopic: Boolean = false,
) {
Column(
modifier = modifier,
@ -67,13 +73,17 @@ internal fun InterestsScreen(
modifier = modifier,
contentDesc = stringResource(id = R.string.feature_interests_loading),
)
is InterestsUiState.Interests ->
TopicsTabContent(
topics = uiState.topics,
onTopicClick = onTopicClick,
onFollowButtonClick = followTopic,
selectedTopicId = uiState.selectedTopicId,
highlightSelectedTopic = highlightSelectedTopic,
modifier = modifier,
)
is InterestsUiState.Empty -> InterestsEmptyScreen()
}
}
@ -95,6 +105,7 @@ fun InterestsScreenPopulated(
NiaBackground {
InterestsScreen(
uiState = InterestsUiState.Interests(
selectedTopicId = null,
topics = followableTopics,
),
followTopic = { _, _ -> },

@ -16,46 +16,57 @@
package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class InterestsViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
val userDataRepository: UserDataRepository,
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
val uiState: StateFlow<InterestsUiState> =
getFollowableTopics(sortBy = TopicSortField.NAME).map(
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null)
val uiState: StateFlow<InterestsUiState> = combine(
selectedTopicId,
getFollowableTopics(sortBy = TopicSortField.NAME),
InterestsUiState::Interests,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InterestsUiState.Loading,
)
fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch {
userDataRepository.setTopicIdFollowed(followedTopicId, followed)
}
}
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId
}
}
sealed interface InterestsUiState {
data object Loading : InterestsUiState
data class Interests(
val selectedTopicId: String?,
val topics: List<FollowableTopic>,
) : InterestsUiState

@ -47,6 +47,8 @@ fun TopicsTabContent(
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true,
selectedTopicId: String? = null,
highlightSelectedTopic: Boolean = false,
) {
Box(
modifier = modifier
@ -63,6 +65,7 @@ fun TopicsTabContent(
topics.forEach { followableTopic ->
val topicId = followableTopic.topic.id
item(key = topicId) {
val isSelected = highlightSelectedTopic && topicId == selectedTopicId
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
@ -70,6 +73,7 @@ fun TopicsTabContent(
topicImageUrl = followableTopic.topic.imageUrl,
onClick = { onTopicClick(topicId) },
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
isSelected = isSelected,
)
}
}

@ -19,26 +19,37 @@ package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph"
const val INTERESTS_ROUTE = "interests_route"
const val TOPIC_ID_ARG = "topicId"
const val INTERESTS_ROUTE_BASE = "interests_route"
const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}"
fun NavController.navigateToInterestsGraph(navOptions: NavOptions) = navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions)
fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) {
val route = if (topicId != null) {
"${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId"
} else {
INTERESTS_ROUTE_BASE
}
navigate(route, navOptions)
}
fun NavGraphBuilder.interestsGraph(
fun NavGraphBuilder.interestsScreen(
onTopicClick: (String) -> Unit,
nestedGraphs: NavGraphBuilder.() -> Unit,
) {
navigation(
route = INTERESTS_GRAPH_ROUTE_PATTERN,
startDestination = INTERESTS_ROUTE,
composable(
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
defaultValue = null
nullable = true
type = NavType.StringType
},
),
) {
composable(route = INTERESTS_ROUTE) {
InterestsRoute(onTopicClick)
}
nestedGraphs()
InterestsRoute(onTopicClick = onTopicClick)
}
}

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.interests
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
@ -24,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -53,6 +55,7 @@ class InterestsViewModelTest {
@Before
fun setup() {
viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
)
@ -93,7 +96,10 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testOutputTopics),
InterestsUiState.Interests(
topics = testOutputTopics,
selectedTopicId = testInputTopics[0].topic.id,
),
viewModel.uiState.value,
)
@ -123,7 +129,10 @@ class InterestsViewModelTest {
)
assertEquals(
InterestsUiState.Interests(topics = testInputTopics),
InterestsUiState.Interests(
topics = testInputTopics,
selectedTopicId = testInputTopics[0].topic.id,
),
viewModel.uiState.value,
)

@ -55,6 +55,7 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Loading,
showBackButton = true,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
@ -75,6 +76,7 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Success(testTopic),
newsUiState = NewsUiState.Loading,
showBackButton = true,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
@ -100,6 +102,7 @@ class TopicScreenTest {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Success(userNewsResourcesTestData),
showBackButton = true,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},
@ -123,6 +126,7 @@ class TopicScreenTest {
newsUiState = NewsUiState.Success(
userNewsResourcesTestData,
),
showBackButton = true,
onBackClick = {},
onFollowClick = {},
onTopicClick = {},

@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
@ -44,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -70,6 +72,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.R.string
@Composable
internal fun TopicRoute(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
@ -82,7 +85,8 @@ internal fun TopicRoute(
TopicScreen(
topicUiState = topicUiState,
newsUiState = newsUiState,
modifier = modifier,
modifier = modifier.testTag("topic:${viewModel.topicId}"),
showBackButton = showBackButton,
onBackClick = onBackClick,
onFollowClick = viewModel::followTopicToggle,
onBookmarkChanged = viewModel::bookmarkNews,
@ -96,6 +100,7 @@ internal fun TopicRoute(
internal fun TopicScreen(
topicUiState: TopicUiState,
newsUiState: NewsUiState,
showBackButton: Boolean,
onBackClick: () -> Unit,
onFollowClick: (Boolean) -> Unit,
onTopicClick: (String) -> Unit,
@ -127,6 +132,7 @@ internal fun TopicScreen(
is TopicUiState.Success -> {
item {
TopicToolbar(
showBackButton = showBackButton,
onBackClick = onBackClick,
onFollowClick = onFollowClick,
uiState = topicUiState.followableTopic,
@ -270,6 +276,7 @@ private fun TopicBodyPreview() {
private fun TopicToolbar(
uiState: FollowableTopic,
modifier: Modifier = Modifier,
showBackButton: Boolean = true,
onBackClick: () -> Unit = {},
onFollowClick: (Boolean) -> Unit = {},
) {
@ -280,13 +287,18 @@ private fun TopicToolbar(
.fillMaxWidth()
.padding(bottom = 32.dp),
) {
IconButton(onClick = { onBackClick() }) {
Icon(
imageVector = NiaIcons.ArrowBack,
contentDescription = stringResource(
id = com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_back,
),
)
if (showBackButton) {
IconButton(onClick = { onBackClick() }) {
Icon(
imageVector = NiaIcons.ArrowBack,
contentDescription = stringResource(
id = com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_back,
),
)
}
} else {
// Keeps the NiaFilterChip aligned to the end of the Row.
Spacer(modifier = Modifier.width(1.dp))
}
val selected = uiState.isFollowed
NiaFilterChip(
@ -314,6 +326,7 @@ fun TopicScreenPopulated(
TopicScreen(
topicUiState = TopicUiState.Success(userNewsResources[0].followableTopics[0]),
newsUiState = NewsUiState.Success(userNewsResources),
showBackButton = true,
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },
@ -332,6 +345,7 @@ fun TopicScreenLoading() {
TopicScreen(
topicUiState = TopicUiState.Loading,
newsUiState = NewsUiState.Loading,
showBackButton = true,
onBackClick = {},
onFollowClick = {},
onBookmarkChanged = { _, _ -> },

@ -20,6 +20,7 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
@ -32,20 +33,23 @@ private val URL_CHARACTER_ENCODING = UTF_8.name()
@VisibleForTesting
internal const val TOPIC_ID_ARG = "topicId"
const val TOPIC_ROUTE = "topic_route"
internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle) :
this(URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING))
}
fun NavController.navigateToTopic(topicId: String) {
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
navigate("topic_route/$encodedId") {
launchSingleTop = true
val newRoute = "$TOPIC_ROUTE/$encodedId"
navigate(newRoute) {
navOptions()
}
}
fun NavGraphBuilder.topicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
@ -55,6 +59,10 @@ fun NavGraphBuilder.topicScreen(
navArgument(TOPIC_ID_ARG) { type = NavType.StringType },
),
) {
TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick)
TopicRoute(
showBackButton = showBackButton,
onBackClick = onBackClick,
onTopicClick = onTopicClick,
)
}
}

@ -7,8 +7,9 @@ androidTools = "31.3.0"
androidxActivity = "1.8.0"
androidxAppCompat = "1.6.1"
androidxBrowser = "1.6.0"
androidxComposeBom = "2024.02.00"
androidxComposeBom = "2024.02.01"
androidxComposeCompiler = "1.5.8"
androidxComposeMaterial3Adaptive = "1.0.0-alpha06"
androidxComposeRuntimeTracing = "1.0.0-beta01"
androidxCore = "1.12.0"
androidxCoreSplashscreen = "1.0.1"
@ -70,6 +71,7 @@ androidx-compose-foundation = { group = "androidx.compose.foundation", name = "f
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3", name = "material3-adaptive", version.ref = "androidxComposeMaterial3Adaptive" }
androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" }

Loading…
Cancel
Save