diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0a6331c7f..7a3ada333 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,12 +116,6 @@ dependencies { implementation(libs.androidx.compose.runtime) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.compose.runtime.tracing) - implementation(libs.androidx.compose.material3.adaptive) { - this.isTransitive = false - } - implementation(libs.androidx.compose.material3.adaptive.navigation.suite) { - this.isTransitive = false - } implementation(libs.androidx.compose.material3.windowSizeClass) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.navigation.compose) 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 58672f8e0..5861cda58 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 @@ -20,6 +20,8 @@ import androidx.annotation.StringRes import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -218,6 +220,14 @@ class NavigationTest { onNodeWithText(saved).performClick() onNodeWithContentDescription(settings).performClick() onNodeWithText(ok).performClick() + + // Check that the saved screen is still visible and selected. + onNode( + hasText(saved) and + hasAnyAncestor( + hasTestTag("NiaBottomBar") or hasTestTag("NiaNavRail"), + ), + ).assertIsSelected() } } diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt new file mode 100644 index 000000000..d92390918 --- /dev/null +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt @@ -0,0 +1,268 @@ +/* + * 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.ui + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.google.accompanist.testharness.TestHarness +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule +import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import javax.inject.Inject + +/** + * Tests that the navigation UI is rendered correctly on different screen sizes. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@HiltAndroidTest +class NavigationUiTest { + + /** + * Manages the components' state and is used to perform injection on your test + */ + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + /** + * Create a temporary folder used to create a Data Store file. This guarantees that + * the file is removed in between each test, preventing a crash. + */ + @BindValue + @get:Rule(order = 1) + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + /** + * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission. + */ + @get:Rule(order = 2) + val postNotificationsPermission = GrantPostNotificationsPermissionRule() + + /** + * Use a test activity to set the content on. + */ + @get:Rule(order = 3) + val composeTestRule = createAndroidComposeRule() + + val userNewsResourceRepository = CompositeUserNewsResourceRepository( + newsRepository = TestNewsRepository(), + userDataRepository = TestUserDataRepository(), + ) + + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun compactWidth_compactHeight_showsNavigationBar() { + composeTestRule.setContent { + TestHarness(size = DpSize(400.dp, 400.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() + } + + @Test + fun mediumWidth_compactHeight_showsNavigationRail() { + composeTestRule.setContent { + TestHarness(size = DpSize(610.dp, 400.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() + } + + @Test + fun expandedWidth_compactHeight_showsNavigationRail() { + composeTestRule.setContent { + TestHarness(size = DpSize(900.dp, 400.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() + } + + @Test + fun compactWidth_mediumHeight_showsNavigationBar() { + composeTestRule.setContent { + TestHarness(size = DpSize(400.dp, 500.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() + } + + @Test + fun mediumWidth_mediumHeight_showsNavigationRail() { + composeTestRule.setContent { + TestHarness(size = DpSize(610.dp, 500.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() + } + + @Test + fun expandedWidth_mediumHeight_showsNavigationRail() { + composeTestRule.setContent { + TestHarness(size = DpSize(900.dp, 500.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() + } + + @Test + fun compactWidth_expandedHeight_showsNavigationBar() { + composeTestRule.setContent { + TestHarness(size = DpSize(400.dp, 1000.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() + } + + @Test + fun mediumWidth_expandedHeight_showsNavigationRail() { + composeTestRule.setContent { + TestHarness(size = DpSize(610.dp, 1000.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() + } + + @Test + fun expandedWidth_expandedHeight_showsNavigationRail() { + composeTestRule.setContent { + TestHarness(size = DpSize(900.dp, 1000.dp)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + + composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed() + composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist() + } +} diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index f730a7544..1560a74eb 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -16,6 +16,8 @@ package com.google.samples.apps.nowinandroid.ui +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -39,6 +41,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue /** @@ -47,6 +50,7 @@ import kotlin.test.assertTrue * Note: This could become an unit test if Robolectric is added to the project and the Context * is faked. */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) class NiaAppStateTest { @get:Rule @@ -71,7 +75,7 @@ class NiaAppStateTest { NiaAppState( navController = navController, coroutineScope = backgroundScope, - windowSize = getCompactWindowSize(), + windowSizeClass = getCompactWindowClass(), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, ) @@ -93,7 +97,7 @@ class NiaAppStateTest { fun niaAppState_destinations() = runTest { composeTestRule.setContent { state = rememberNiaAppState( - windowSize = getCompactWindowSize(), + windowSizeClass = getCompactWindowClass(), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, ) @@ -105,13 +109,61 @@ class NiaAppStateTest { assertTrue(state.topLevelDestinations[2].name.contains("interests", true)) } + @Test + fun niaAppState_showBottomBar_compact() = runTest { + composeTestRule.setContent { + state = NiaAppState( + navController = NavHostController(LocalContext.current), + coroutineScope = backgroundScope, + windowSizeClass = getCompactWindowClass(), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + + assertTrue(state.shouldShowBottomBar) + assertFalse(state.shouldShowNavRail) + } + + @Test + fun niaAppState_showNavRail_medium() = runTest { + composeTestRule.setContent { + state = NiaAppState( + navController = NavHostController(LocalContext.current), + coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + + assertTrue(state.shouldShowNavRail) + assertFalse(state.shouldShowBottomBar) + } + + @Test + fun niaAppState_showNavRail_large() = runTest { + composeTestRule.setContent { + state = NiaAppState( + navController = NavHostController(LocalContext.current), + coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + + assertTrue(state.shouldShowNavRail) + assertFalse(state.shouldShowBottomBar) + } + @Test fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { composeTestRule.setContent { state = NiaAppState( navController = NavHostController(LocalContext.current), coroutineScope = backgroundScope, - windowSize = DpSize(900.dp, 1200.dp), + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, ) @@ -125,7 +177,7 @@ class NiaAppStateTest { ) } - private fun getCompactWindowSize() = DpSize(500.dp, 300.dp) + private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp)) } @Composable 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 59b3d4184..7fe1bc674 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 @@ -24,17 +24,14 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.collectWindowSizeAsState +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.IntSize import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -62,6 +59,7 @@ import javax.inject.Inject private const val TAG = "MainActivity" +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -82,7 +80,6 @@ class MainActivity : ComponentActivity() { val viewModel: MainActivityViewModel by viewModels() - @OptIn(ExperimentalMaterial3AdaptiveApi::class) override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) @@ -142,10 +139,9 @@ class MainActivity : ComponentActivity() { androidTheme = shouldUseAndroidTheme(uiState), disableDynamicTheming = shouldDisableDynamicTheming(uiState), ) { - val windowSize by collectWindowSizeAsState() NiaApp( - windowSize = windowSize.toDpSize(), networkMonitor = networkMonitor, + windowSizeClass = calculateWindowSizeClass(this), userNewsResourceRepository = userNewsResourceRepository, ) } @@ -241,11 +237,6 @@ private fun shouldUseDarkTheme( } } -@Composable -private fun IntSize.toDpSize(): DpSize = with(LocalDensity.current) { - DpSize(width.toDp(), height.toDp()) -} - /** * The default light scrim, as defined by androidx and the platform: * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598 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 7c536668e..aa85afebd 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 @@ -16,10 +16,18 @@ package com.google.samples.apps.nowinandroid.ui +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -31,9 +39,7 @@ 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.navigation.suite.ExperimentalMaterial3AdaptiveNavigationSuiteApi -import androidx.compose.material3.adaptive.navigation.suite.NavigationSuiteDefaults -import androidx.compose.material3.adaptive.navigation.suite.NavigationSuiteScaffold +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,10 +47,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy @@ -53,6 +66,10 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground +import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar +import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem +import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail +import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors @@ -64,16 +81,17 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR @OptIn( ExperimentalMaterial3Api::class, - ExperimentalMaterial3AdaptiveNavigationSuiteApi::class, + ExperimentalLayoutApi::class, + ExperimentalComposeUiApi::class, ) @Composable fun NiaApp( - windowSize: DpSize, + windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, appState: NiaAppState = rememberNiaAppState( networkMonitor = networkMonitor, - windowSize = windowSize, + windowSizeClass = windowSizeClass, userNewsResourceRepository = userNewsResourceRepository, ), ) { @@ -113,48 +131,52 @@ fun NiaApp( } val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() - val currentDestination = appState.currentDestination - NavigationSuiteScaffold( - layoutType = appState.navigationSuiteType, + Scaffold( + modifier = Modifier.semantics { + testTagsAsResourceId = true + }, containerColor = Color.Transparent, - navigationSuiteColors = NavigationSuiteDefaults.colors( - navigationRailContainerColor = Color.Transparent, - navigationDrawerContainerColor = Color.Transparent, - ), - navigationSuiteItems = { - appState.topLevelDestinations.forEach { destination -> - val isSelected = - currentDestination.isTopLevelDestinationInHierarchy(destination) - val isUnread = unreadDestinations.contains(destination) - item( - selected = isSelected, - icon = { - BadgedBox( - badge = { - if (isUnread) { - Badge() - } - }, - ) { - Icon( - imageVector = if (isSelected) { - destination.selectedIcon - } else { - destination.unselectedIcon - }, - contentDescription = null, - ) - } - }, - label = { Text(stringResource(destination.iconTextId)) }, - onClick = { appState.navigateToTopLevelDestination(destination) }, + contentColor = MaterialTheme.colorScheme.onBackground, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + if (appState.shouldShowBottomBar) { + NiaBottomBar( + destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = unreadDestinations, + onNavigateToDestination = appState::navigateToTopLevelDestination, + currentDestination = appState.currentDestination, + modifier = Modifier.testTag("NiaBottomBar"), ) } }, - ) { - Scaffold( - topBar = { + ) { padding -> + Row( + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, + ), + ), + ) { + if (appState.shouldShowNavRail) { + NiaNavRail( + destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = unreadDestinations, + onNavigateToDestination = appState::navigateToTopLevelDestination, + currentDestination = appState.currentDestination, + modifier = Modifier + .testTag("NiaNavRail") + .safeDrawingPadding(), + ) + } + + Column(Modifier.fillMaxSize()) { + // Show the top app bar on top level destinations. val destination = appState.currentTopLevelDestination if (destination != null) { NiaTopAppBar( @@ -174,29 +196,113 @@ fun NiaApp( onNavigationClick = { appState.navigateToSearch() }, ) } - }, - contentWindowInsets = WindowInsets(0, 0, 0, 0), - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - ) { padding -> - NiaNavHost( - appState = appState, - onShowSnackbar = { message, action -> + + NiaNavHost(appState = appState, onShowSnackbar = { message, action -> snackbarHostState.showSnackbar( message = message, actionLabel = action, duration = Short, ) == ActionPerformed - }, - modifier = Modifier.padding(padding), - ) + }) + } + + // TODO: We may want to add padding or spacer when the snackbar is shown so that + // content doesn't display behind it. } } } } } +@Composable +private fun NiaNavRail( + destinations: List, + destinationsWithUnreadResources: Set, + onNavigateToDestination: (TopLevelDestination) -> Unit, + currentDestination: NavDestination?, + modifier: Modifier = Modifier, +) { + NiaNavigationRail(modifier = modifier) { + destinations.forEach { destination -> + val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + val hasUnread = destinationsWithUnreadResources.contains(destination) + NiaNavigationRailItem( + selected = selected, + onClick = { onNavigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = null, + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = null, + ) + }, + label = { Text(stringResource(destination.iconTextId)) }, + modifier = if (hasUnread) Modifier.notificationDot() else Modifier, + ) + } + } +} + +@Composable +private fun NiaBottomBar( + destinations: List, + destinationsWithUnreadResources: Set, + onNavigateToDestination: (TopLevelDestination) -> Unit, + currentDestination: NavDestination?, + modifier: Modifier = Modifier, +) { + NiaNavigationBar( + modifier = modifier, + ) { + destinations.forEach { destination -> + val hasUnread = destinationsWithUnreadResources.contains(destination) + val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + NiaNavigationBarItem( + selected = selected, + onClick = { onNavigateToDestination(destination) }, + icon = { + Icon( + imageVector = destination.unselectedIcon, + contentDescription = null, + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = null, + ) + }, + label = { Text(stringResource(destination.iconTextId)) }, + modifier = if (hasUnread) Modifier.notificationDot() else Modifier, + ) + } + } +} + +private fun Modifier.notificationDot(): Modifier = + composed { + val tertiaryColor = MaterialTheme.colorScheme.tertiary + drawWithContent { + drawContent() + drawCircle( + tertiaryColor, + radius = 5.dp.toPx(), + // This is based on the dimensions of the NavigationBar's "indicator pill"; + // however, its parameters are private, so we must depend on them implicitly + // (NavigationBarTokens.ActiveIndicatorWidth = 64.dp) + center = center + Offset( + 64.dp.toPx() * .45f, + 32.dp.toPx() * -.45f - 6.dp.toPx(), + ), + ) + } + } + private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = this?.hierarchy?.any { it.route?.contains(destination.name, true) ?: false 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 861252081..09e70069e 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 @@ -16,14 +16,12 @@ package com.google.samples.apps.nowinandroid.ui -import androidx.compose.material3.adaptive.navigation.suite.ExperimentalMaterial3AdaptiveNavigationSuiteApi -import androidx.compose.material3.adaptive.navigation.suite.NavigationSuiteType +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavGraph.Companion.findStartDestination @@ -55,7 +53,7 @@ import kotlinx.coroutines.flow.stateIn @Composable fun rememberNiaAppState( - windowSize: DpSize, + windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, coroutineScope: CoroutineScope = rememberCoroutineScope(), @@ -65,14 +63,14 @@ fun rememberNiaAppState( return remember( navController, coroutineScope, - windowSize, + windowSizeClass, networkMonitor, userNewsResourceRepository, ) { NiaAppState( navController, coroutineScope, - windowSize, + windowSizeClass, networkMonitor, userNewsResourceRepository, ) @@ -83,7 +81,7 @@ fun rememberNiaAppState( class NiaAppState( val navController: NavHostController, val coroutineScope: CoroutineScope, - private val windowSize: DpSize, + val windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, ) { @@ -99,6 +97,12 @@ class NiaAppState( else -> null } + val shouldShowBottomBar: Boolean + get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact + + val shouldShowNavRail: Boolean + get() = !shouldShowBottomBar + val isOffline = networkMonitor.isOnline .map(Boolean::not) .stateIn( @@ -129,26 +133,6 @@ class NiaAppState( initialValue = emptySet(), ) - /** - * Per Material Design 3 guidelines, - * the selection of the appropriate navigation component should be contingent on the available - * window size: - * - Bottom Bar for compact window sizes (below 600dp) - * - Navigation Rail for medium and expanded window sizes up to 1240dp (between 600dp and 1240dp) - * - Navigation Drawer to window size above 1240dp - */ - @OptIn(ExperimentalMaterial3AdaptiveNavigationSuiteApi::class) - val navigationSuiteType: NavigationSuiteType - @Composable get() { - return if (windowSize.width > 1240.dp) { - NavigationSuiteType.NavigationDrawer - } else if (windowSize.width >= 600.dp) { - NavigationSuiteType.NavigationRail - } else { - NavigationSuiteType.NavigationBar - } - } - /** * UI logic for navigating to a top level destination in the app. Top level destinations have * only one copy of the destination of the back stack, and save and restore state whenever you diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt index 89be3dc5a..bac088482 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -17,6 +17,9 @@ package com.google.samples.apps.nowinandroid.ui import android.util.Log +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -57,11 +60,12 @@ import javax.inject.Inject /** * Tests that the navigation UI is rendered correctly on different screen sizes. */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) // Configure Robolectric to use a very large screen size that can fit all of the test sizes. // This allows enough room to render the content under test without clipping or scaling. -@Config(application = HiltTestApplication::class, qualifiers = "w1300dp-h1000dp-480dpi", sdk = [33]) +@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi", sdk = [33]) @LooperMode(LooperMode.Mode.PAUSED) @HiltAndroidTest class NiaAppScreenSizesScreenshotTests { @@ -134,13 +138,16 @@ class NiaAppScreenSizesScreenshotTests { CompositionLocalProvider( LocalInspectionMode provides true, ) { - val windowSize = DpSize(width, height) - TestHarness(size = windowSize) { - NiaApp( - windowSize = windowSize, - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + TestHarness(size = DpSize(width, height)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } } } } @@ -232,31 +239,4 @@ class NiaAppScreenSizesScreenshotTests { "expandedWidth_expandedHeight_showsNavigationRail", ) } - - @Test - fun largeScreenWidth_compactHeight_showsPermanentDrawer() { - testNiaAppScreenshotWithSize( - 1300.dp, - 400.dp, - "largeScreenWidth_compactHeight_showsPermanentDrawer", - ) - } - - @Test - fun largeScreenWidth_mediumHeight_showsPermanentDrawer() { - testNiaAppScreenshotWithSize( - 1300.dp, - 500.dp, - "largeScreenWidth_mediumHeight_showsPermanentDrawer", - ) - } - - @Test - fun largeScreenWidth_expandedHeight_showsPermanentDrawer() { - testNiaAppScreenshotWithSize( - 1300.dp, - 1000.dp, - "largeScreenWidth_expandedHeight_showsPermanentDrawer", - ) - } } diff --git a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png index 405f24943..edb9cfa2a 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png index 59add45d5..035cc24cf 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/compactWidth_mediumHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png index 737760276..7749199c5 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png index 7bc90d775..fe5b045aa 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png index 752d6e134..523b03ec5 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/expandedWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png index 621bbf24d..58d620f21 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/largeScreenWidth_compactHeight_showsPermanentDrawer.png b/app/src/testDemo/screenshots/largeScreenWidth_compactHeight_showsPermanentDrawer.png deleted file mode 100644 index 11a03633a..000000000 Binary files a/app/src/testDemo/screenshots/largeScreenWidth_compactHeight_showsPermanentDrawer.png and /dev/null differ diff --git a/app/src/testDemo/screenshots/largeScreenWidth_expandedHeight_showsPermanentDrawer.png b/app/src/testDemo/screenshots/largeScreenWidth_expandedHeight_showsPermanentDrawer.png deleted file mode 100644 index 8516b2ef0..000000000 Binary files a/app/src/testDemo/screenshots/largeScreenWidth_expandedHeight_showsPermanentDrawer.png and /dev/null differ diff --git a/app/src/testDemo/screenshots/largeScreenWidth_mediumHeight_showsPermanentDrawer.png b/app/src/testDemo/screenshots/largeScreenWidth_mediumHeight_showsPermanentDrawer.png deleted file mode 100644 index 80cc30439..000000000 Binary files a/app/src/testDemo/screenshots/largeScreenWidth_mediumHeight_showsPermanentDrawer.png and /dev/null differ diff --git a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png index 412e547df..56b49457c 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png index 0a7f0cdc7..15ddccf78 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/mediumWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png index 690079ee2..d2e4bb8bc 100644 Binary files a/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png differ diff --git a/feature/bookmarks/build.gradle.kts b/feature/bookmarks/build.gradle.kts index f381c8a23..32394f911 100644 --- a/feature/bookmarks/build.gradle.kts +++ b/feature/bookmarks/build.gradle.kts @@ -23,3 +23,7 @@ plugins { android { namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks" } + +dependencies { + implementation(libs.androidx.compose.material3.windowSizeClass) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2cff441c8..1eb9116e8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,8 +18,6 @@ androidxHiltNavigationCompose = "1.0.0" androidxJunit = "1.1.5" androidxLifecycle = "2.6.2" androidxMacroBenchmark = "1.2.0" -androidxComposeMaterial3Adaptive = "1.0.0-alpha01" -androidxComposeMaterial3AdaptiveNavigationSuite = "1.0.0-alpha01" androidxMetrics = "1.0.0-alpha04" androidxNavigation = "2.7.4" androidxProfileinstaller = "1.3.1" @@ -73,8 +71,6 @@ 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-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "androidxComposeMaterial3AdaptiveNavigationSuite" } 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-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }