diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2ea8de3d..60661a90f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,10 +68,15 @@ android { dependencies { implementation(projects.feature.interests.api) + implementation(projects.feature.interests.impl) implementation(projects.feature.foryou.api) + implementation(projects.feature.foryou.impl) implementation(projects.feature.bookmarks.api) + implementation(projects.feature.bookmarks.impl) implementation(projects.feature.topic.api) + implementation(projects.feature.topic.impl) implementation(projects.feature.search.api) + implementation(projects.feature.search.impl) implementation(projects.feature.settings.api) implementation(projects.core.common) @@ -81,10 +86,13 @@ dependencies { implementation(projects.core.model) implementation(projects.core.navigation) implementation(projects.core.analytics) + implementation(projects.core.navigation) implementation(projects.sync.work) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.navigation) diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt index ecc23d80e..03557441b 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -32,6 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.metrics.performance.JankStats +import androidx.navigation3.runtime.EntryProviderBuilder import androidx.tracing.trace import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper @@ -41,6 +42,7 @@ import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme @@ -72,9 +74,14 @@ class MainActivity : ComponentActivity() { @Inject lateinit var userNewsResourceRepository: UserNewsResourceRepository - private val viewModel: MainActivityViewModel by viewModels() + @Inject + lateinit var niaBackStack: NiaBackStack + + @Inject + lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit> + override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) @@ -137,6 +144,7 @@ class MainActivity : ComponentActivity() { networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = niaBackStack, ) val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle() @@ -150,7 +158,10 @@ class MainActivity : ComponentActivity() { androidTheme = themeSettings.androidTheme, disableDynamicTheming = themeSettings.disableDynamicTheming, ) { - NiaApp(appState) + NiaApp( + appState, + entryProviderBuilders + ) } } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/BackStackProvider.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/BackStackProvider.kt index 877e93910..aa089a4d8 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/BackStackProvider.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/BackStackProvider.kt @@ -30,5 +30,5 @@ object NiaAppNavigation { @Provides @Singleton fun provideNiaBackStack(): NiaBackStack = - NiaBackStack(startKey = TopLevelDestination.FOR_YOU) + NiaBackStack(startKey = TopLevelDestination.FOR_YOU.key) } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavDisplay.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavDisplay.kt new file mode 100644 index 000000000..48ab8a7b3 --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavDisplay.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 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.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack + +@Composable +fun NiaNavDisplay( + niaBackStack: NiaBackStack, + entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit>, +) { + NavDisplay( + backStack = niaBackStack.backStack, + onBack = { niaBackStack.removeLast() }, + entryProvider = entryProvider { + entryProviderBuilders.forEach { builder -> + builder() + } + }, + ) +} \ No newline at end of file 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 1f8f88686..ec4224efd 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 @@ -28,7 +28,7 @@ import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigat import com.google.samples.apps.nowinandroid.feature.topic.api.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 +import com.google.samples.apps.nowinandroid.feature.interests.impl.interestsListDetailScreen /** * Top-level navigation graph. Navigation is organized as explained at 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 68f7c6b1a..edce9517a 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 @@ -50,6 +50,7 @@ enum class TopLevelDestination( @StringRes val titleTextId: Int, val route: KClass<*>, val baseRoute: KClass<*> = route, + val key: Any ) { FOR_YOU( selectedIcon = NiaIcons.Upcoming, @@ -58,6 +59,7 @@ enum class TopLevelDestination( titleTextId = R.string.app_name, route = ForYouRoute::class, baseRoute = ForYouBaseRoute::class, + key = ForYouBaseRoute ), BOOKMARKS( selectedIcon = NiaIcons.Bookmarks, @@ -65,6 +67,7 @@ enum class TopLevelDestination( iconTextId = bookmarksR.string.feature_bookmarks_impl_title, titleTextId = bookmarksR.string.feature_bookmarks_impl_title, route = BookmarksRoute::class, + key = BookmarksRoute ), INTERESTS( selectedIcon = NiaIcons.Grid3x3, @@ -72,5 +75,6 @@ enum class TopLevelDestination( iconTextId = searchR.string.feature_search_api_interests, titleTextId = searchR.string.feature_search_api_interests, route = InterestsRoute::class, + key = InterestsRoute(null) ), } 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 fe2d758ee..9a65765c0 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 @@ -33,15 +33,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration.Indefinite -import androidx.compose.material3.SnackbarDuration.Short import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult.ActionPerformed import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,6 +62,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation3.runtime.EntryProviderBuilder import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground @@ -71,8 +71,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors +import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsDialog -import com.google.samples.apps.nowinandroid.navigation.NiaNavHost +import com.google.samples.apps.nowinandroid.navigation.NiaNavDisplay import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import kotlin.reflect.KClass import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR @@ -80,6 +81,7 @@ import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR @Composable fun NiaApp( appState: NiaAppState, + entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit>, modifier: Modifier = Modifier, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), ) { @@ -109,15 +111,16 @@ fun NiaApp( ) } } - - NiaApp( - appState = appState, - snackbarHostState = snackbarHostState, - showSettingsDialog = showSettingsDialog, - onSettingsDismissed = { showSettingsDialog = false }, - onTopAppBarActionClick = { showSettingsDialog = true }, - windowAdaptiveInfo = windowAdaptiveInfo, - ) + CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) { + NiaApp( + appState = appState, + entryProviderBuilders = entryProviderBuilders, + showSettingsDialog = showSettingsDialog, + onSettingsDismissed = { showSettingsDialog = false }, + onTopAppBarActionClick = { showSettingsDialog = true }, + windowAdaptiveInfo = windowAdaptiveInfo, + ) + } } } } @@ -129,7 +132,7 @@ fun NiaApp( ) internal fun NiaApp( appState: NiaAppState, - snackbarHostState: SnackbarHostState, + entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit>, showSettingsDialog: Boolean, onSettingsDismissed: () -> Unit, onTopAppBarActionClick: () -> Unit, @@ -138,7 +141,8 @@ internal fun NiaApp( ) { val unreadDestinations by appState.topLevelDestinationsWithUnreadResources .collectAsStateWithLifecycle() - val currentDestination = appState.currentDestination + val currentTopLevelKey = appState.currentTopLevelDestination + if (showSettingsDialog) { SettingsDialog( @@ -146,12 +150,16 @@ internal fun NiaApp( ) } + val snackbarHostState = LocalSnackbarHostState.current + NiaNavigationSuiteScaffold( navigationSuiteItems = { appState.topLevelDestinations.forEach { destination -> val hasUnread = unreadDestinations.contains(destination) - val selected = currentDestination - .isRouteInHierarchy(destination.baseRoute) +// val selected = currentDestination +// .isRouteInHierarchy(destination.baseRoute) + val selected = destination.key == currentTopLevelKey + println("cfok destination:$destination, currentDest:$currentTopLevelKey") item( selected = selected, onClick = { appState.navigateToTopLevelDestination(destination) }, @@ -225,7 +233,7 @@ internal fun NiaApp( containerColor = Color.Transparent, ), onActionClick = { onTopAppBarActionClick() }, - onNavigationClick = { appState.navigateToSearch() }, + onNavigationClick = { appState.navigateToSearchNav3() }, ) } @@ -239,15 +247,13 @@ internal fun NiaApp( }, ), ) { - NiaNavHost( - appState = appState, - onShowSnackbar = { message, action -> - snackbarHostState.showSnackbar( - message = message, - actionLabel = action, - duration = Short, - ) == ActionPerformed - }, +// NiaNavHost( +// appState = appState, +// onShowSnackbar = onShowSnackbar, +// ) + NiaNavDisplay( + niaBackStack = appState.niaBackStack, + entryProviderBuilders, ) } 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 4c1d23318..0eba2d0db 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 @@ -18,26 +18,19 @@ package com.google.samples.apps.nowinandroid.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavController import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController -import androidx.navigation.navOptions -import androidx.tracing.trace import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank -import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.navigateToBookmarks -import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.navigateToForYou -import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU @@ -55,11 +48,13 @@ fun rememberNiaAppState( networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, timeZoneMonitor: TimeZoneMonitor, + niaBackStack: NiaBackStack, coroutineScope: CoroutineScope = rememberCoroutineScope(), navController: NavHostController = rememberNavController(), ): NiaAppState { NavigationTrackingSideEffect(navController) return remember( + niaBackStack, navController, coroutineScope, networkMonitor, @@ -67,6 +62,7 @@ fun rememberNiaAppState( timeZoneMonitor, ) { NiaAppState( + niaBackStack = niaBackStack, navController = navController, coroutineScope = coroutineScope, networkMonitor = networkMonitor, @@ -78,6 +74,7 @@ fun rememberNiaAppState( @Stable class NiaAppState( + val niaBackStack: NiaBackStack, val navController: NavHostController, coroutineScope: CoroutineScope, networkMonitor: NetworkMonitor, @@ -86,24 +83,25 @@ class NiaAppState( ) { private val previousDestination = mutableStateOf(null) - val currentDestination: NavDestination? - @Composable get() { - // Collect the currentBackStackEntryFlow as a state - val currentEntry = navController.currentBackStackEntryFlow - .collectAsState(initial = null) - - // Fallback to previousDestination if currentEntry is null - return currentEntry.value?.destination.also { destination -> - if (destination != null) { - previousDestination.value = destination - } - } ?: previousDestination.value - } +// val currentDestination: NavDestination? +// @Composable get() { +// // Collect the currentBackStackEntryFlow as a state +// val currentEntry = navController.currentBackStackEntryFlow +// .collectAsState(initial = null) +// +// // Fallback to previousDestination if currentEntry is null +// return currentEntry.value?.destination.also { destination -> +// if (destination != null) { +// previousDestination.value = destination +// } +// } ?: previousDestination.value +// } val currentTopLevelDestination: TopLevelDestination? @Composable get() { return TopLevelDestination.entries.firstOrNull { topLevelDestination -> - currentDestination?.hasRoute(route = topLevelDestination.route) == true + topLevelDestination.key == niaBackStack.currentTopLevelKey +// currentDestination?.hasRoute(route = topLevelDestination.route) == true } } @@ -152,31 +150,37 @@ class NiaAppState( * * @param topLevelDestination: The destination the app needs to navigate to. */ - fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) { - trace("Navigation: ${topLevelDestination.name}") { - val topLevelNavOptions = navOptions { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - // Avoid multiple copies of the same destination when - // reselecting the same item - launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true - } - - when (topLevelDestination) { - FOR_YOU -> navController.navigateToForYou(topLevelNavOptions) - BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions) - INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions) - } - } + fun navigateToTopLevelDestination( + topLevelDestination: TopLevelDestination, + ) { + niaBackStack.navigateToTopLevelDestination(topLevelDestination.key) +// trace("Navigation: ${topLevelDestination.name}") { +// val topLevelNavOptions = navOptions { +// // Pop up to the start destination of the graph to +// // avoid building up a large stack of destinations +// // on the back stack as users select items +// popUpTo(navController.graph.findStartDestination().id) { +// saveState = true +// } +// // Avoid multiple copies of the same destination when +// // reselecting the same item +// launchSingleTop = true +// // Restore state when reselecting a previously selected item +// restoreState = true +// } +// +// when (topLevelDestination) { +// FOR_YOU -> navController.navigateToForYou(topLevelNavOptions) +// BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions) +// INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions) +// } +// } } fun navigateToSearch() = navController.navigateToSearch() + fun navigateToSearchNav3() = niaBackStack.navigateToSearch( + onInterestsClick = { navigateToTopLevelDestination(INTERESTS) } + ) } /** @@ -195,4 +199,4 @@ private fun NavigationTrackingSideEffect(navController: NavHostController) { navController.removeOnDestinationChangedListener(listener) } } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt deleted file mode 100644 index 83978a6f4..000000000 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 androidx.navigation.toRoute -import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject - -const val TOPIC_ID_KEY = "selectedTopicId" - -@HiltViewModel -class Interests2PaneViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, -) : ViewModel() { - - val route = savedStateHandle.toRoute() - val selectedTopicId: StateFlow = savedStateHandle.getStateFlow( - key = TOPIC_ID_KEY, - initialValue = route.initialTopicId, - ) - - fun onTopicClick(topicId: String?) { - savedStateHandle[TOPIC_ID_KEY] = topicId - } -} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt deleted file mode 100644 index 07e93eee1..000000000 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt +++ /dev/null @@ -1,244 +0,0 @@ -/* - * 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.animation.AnimatedContent -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.VerticalDragHandle -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.WindowAdaptiveInfo -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.layout.PaneAdaptedValue -import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor -import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem -import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective -import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics -import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior -import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold -import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator -import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.google.samples.apps.nowinandroid.feature.interests.api.InterestsRoute -import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicDetailPlaceholder -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel -import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlin.math.max - -@Serializable internal object TopicPlaceholderRoute - -fun NavGraphBuilder.interestsListDetailScreen() { - composable { - InterestsListDetailScreen() - } -} - -@Composable -internal fun InterestsListDetailScreen( - viewModel: Interests2PaneViewModel = hiltViewModel(), - windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), -) { - val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle() - InterestsListDetailScreen( - selectedTopicId = selectedTopicId, - onTopicClick = viewModel::onTopicClick, - windowAdaptiveInfo = windowAdaptiveInfo, - ) -} - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Composable -internal fun InterestsListDetailScreen( - selectedTopicId: String?, - onTopicClick: (String) -> Unit, - windowAdaptiveInfo: WindowAdaptiveInfo, -) { - val listDetailNavigator = rememberListDetailPaneScaffoldNavigator( - scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo), - initialDestinationHistory = listOfNotNull( - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail).takeIf { - selectedTopicId != null - }, - ), - ) - val coroutineScope = rememberCoroutineScope() - - val paneExpansionState = rememberPaneExpansionState( - anchors = listOf( - PaneExpansionAnchor.Proportion(0f), - PaneExpansionAnchor.Proportion(0.5f), - PaneExpansionAnchor.Proportion(1f), - ), - ) - - ThreePaneScaffoldPredictiveBackHandler( - listDetailNavigator, - BackNavigationBehavior.PopUntilScaffoldValueChange, - ) - BackHandler( - paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(0f) && - listDetailNavigator.isListPaneVisible() && - listDetailNavigator.isDetailPaneVisible(), - ) { - coroutineScope.launch { - paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(1f)) - } - } - - var topicRoute by remember { - val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute - mutableStateOf(route) - } - - fun onTopicClickShowDetailPane(topicId: String) { - onTopicClick(topicId) - topicRoute = TopicRoute(id = topicId) - coroutineScope.launch { - listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) - } - if (paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(1f)) { - coroutineScope.launch { - paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(0f)) - } - } - } - - val mutableInteractionSource = remember { MutableInteractionSource() } - val minPaneWidth = 300.dp - - NavigableListDetailPaneScaffold( - navigator = listDetailNavigator, - listPane = { - AnimatedPane { - Box( - modifier = Modifier.clipToBounds() - .layout { measurable, constraints -> - val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) - val placeable = measurable.measure( - constraints.copy( - minWidth = minPaneWidth.roundToPx(), - maxWidth = width, - ), - ) - layout(constraints.maxWidth, placeable.height) { - placeable.placeRelative( - x = 0, - y = 0, - ) - } - }, - ) { - InterestsRoute( - onTopicClick = ::onTopicClickShowDetailPane, - shouldHighlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(), - ) - } - } - }, - detailPane = { - AnimatedPane { - Box( - modifier = Modifier.clipToBounds() - .layout { measurable, constraints -> - val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) - val placeable = measurable.measure( - constraints.copy( - minWidth = minPaneWidth.roundToPx(), - maxWidth = width, - ), - ) - layout(constraints.maxWidth, placeable.height) { - placeable.placeRelative( - x = constraints.maxWidth - - max(constraints.maxWidth, placeable.width), - y = 0, - ) - } - }, - ) { - AnimatedContent(topicRoute) { route -> - when (route) { - is TopicRoute -> { - TopicScreen( - showBackButton = !listDetailNavigator.isListPaneVisible(), - onBackClick = { - coroutineScope.launch { - listDetailNavigator.navigateBack() - } - }, - onTopicClick = ::onTopicClickShowDetailPane, - viewModel = hiltViewModel( - key = route.id, - ) { factory -> - factory.create(route.id) - }, - ) - } - is TopicPlaceholderRoute -> { - TopicDetailPlaceholder() - } - } - } - } - } - }, - paneExpansionState = paneExpansionState, - paneExpansionDragHandle = { - VerticalDragHandle( - modifier = Modifier.paneExpansionDraggable( - state = paneExpansionState, - minTouchTargetSize = LocalMinimumInteractiveComponentSize.current, - interactionSource = mutableInteractionSource, - semanticsProperties = paneExpansionState.defaultDragHandleSemantics(), - ), - interactionSource = mutableInteractionSource, - ) - }, - ) -} - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -private fun ThreePaneScaffoldNavigator.isListPaneVisible(): Boolean = - scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -private fun ThreePaneScaffoldNavigator.isDetailPaneVisible(): Boolean = - scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt deleted file mode 100644 index 682f4983f..000000000 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * 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 - -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.espresso.Espresso -import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository -import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.model.data.Topic -import com.google.samples.apps.nowinandroid.ui.interests2pane.InterestsListDetailScreen -import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.HiltTestApplication -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import javax.inject.Inject -import kotlin.properties.ReadOnlyProperty -import kotlin.test.assertTrue -import com.google.samples.apps.nowinandroid.feature.topic.api.R as FeatureTopicR - -private const val EXPANDED_WIDTH = "w1200dp-h840dp" -private const val COMPACT_WIDTH = "w412dp-h915dp" - -@HiltAndroidTest -@RunWith(RobolectricTestRunner::class) -@Config(application = HiltTestApplication::class) -class InterestsListDetailScreenTest { - - @get:Rule(order = 0) - val hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - - @Inject - lateinit var topicsRepository: TopicsRepository - - /** Convenience function for getting all topics during tests, */ - private fun getTopics(): List = runBlocking { - topicsRepository.getTopics().first().sortedBy { it.name } - } - - // The strings used for matching in these tests. - private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest) - private val listPaneTag = "interests:topics" - - private val Topic.testTag - get() = "topic:${this.id}" - - @Before - fun setup() { - hiltRule.inject() - } - - @Test - @Config(qualifiers = EXPANDED_WIDTH) - fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsDisplayed() - } - } - - @Test - @Config(qualifiers = COMPACT_WIDTH) - fun compactWidth_initialState_showsListPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - } - } - - @Test - @Config(qualifiers = EXPANDED_WIDTH) - fun expandedWidth_topicSelected_updatesDetailPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - onNodeWithTag(firstTopic.testTag).assertIsDisplayed() - } - } - - @Test - @Config(qualifiers = COMPACT_WIDTH) - fun compactWidth_topicSelected_showsTopicDetailPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - onNodeWithTag(listPaneTag).assertIsNotDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - onNodeWithTag(firstTopic.testTag).assertIsDisplayed() - } - } - - @Test - @Config(qualifiers = EXPANDED_WIDTH) - fun expandedWidth_backPressFromTopicDetail_leavesInterests() { - var unhandledBackPress = false - composeTestRule.apply { - setContent { - NiaTheme { - // Back press should not be handled by the two pane layout, and thus - // "fall through" to this BackHandler. - BackHandler { - unhandledBackPress = true - } - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - waitForIdle() - Espresso.pressBack() - - assertTrue(unhandledBackPress) - } - } - - @Test - @Config(qualifiers = COMPACT_WIDTH) - fun compactWidth_backPressFromTopicDetail_showsListPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - waitForIdle() - Espresso.pressBack() - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed() - } - } -} - -private fun AndroidComposeTestRule<*, *>.stringResource( - @StringRes resId: Int, -): ReadOnlyProperty = - ReadOnlyProperty { _, _ -> activity.getString(resId) } diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index c6ddb54fb..e64a133d5 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -85,7 +85,7 @@ class NiaAppStateTest { } // Update currentDestination whenever it changes - currentDestination = state.currentDestination?.route + currentDestination = state.niaBackStack.currentKey // Navigate to destination b once LaunchedEffect(Unit) { diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index 1af5523c5..5aabc76f7 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -43,6 +43,7 @@ class AndroidFeatureConventionPlugin : Plugin { "implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) "implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get()) "implementation"(libs.findLibrary("androidx.navigation.compose").get()) + "implementation"(libs.findLibrary("androidx.navigation3.runtime").get()) "implementation"(libs.findLibrary("androidx.tracing.ktx").get()) "implementation"(libs.findLibrary("kotlinx.serialization.json").get()) diff --git a/core/designsystem/src/test/resources/robolectric.properties b/core/designsystem/src/test/resources/robolectric.properties new file mode 100644 index 000000000..ca82be153 --- /dev/null +++ b/core/designsystem/src/test/resources/robolectric.properties @@ -0,0 +1,17 @@ +# +# Copyright 2025 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. +# + +sdk = 35 \ No newline at end of file diff --git a/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStack.kt b/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStack.kt index 824ee3205..73934fe77 100644 --- a/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStack.kt +++ b/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStack.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.navigation +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -35,9 +36,12 @@ class NiaBackStack @Inject constructor( ) // Expose the current top level route for consumers - var topLevelKey by mutableStateOf(startKey) + var currentTopLevelKey by mutableStateOf(startKey) private set + internal val currentKey: Any + @Composable get() = topLevelStacks[currentTopLevelKey]!!.last() + private fun updateBackStack() = backStack.apply { clear() @@ -56,21 +60,21 @@ class NiaBackStack @Inject constructor( } } } - topLevelKey = key + currentTopLevelKey = key updateBackStack() } fun navigate(key: Any){ println("cfok navigate $key") - topLevelStacks[topLevelKey]?.add(key) + topLevelStacks[currentTopLevelKey]?.add(key) updateBackStack() } fun removeLast(){ - val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull() + val removedKey = topLevelStacks[currentTopLevelKey]?.removeLastOrNull() // If the removed key was a top level key, remove the associated top level stack topLevelStacks.remove(removedKey) - topLevelKey = topLevelStacks.keys.last() + currentTopLevelKey = topLevelStacks.keys.last() updateBackStack() }