diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac23d51d..c61d32fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(Dependencies.AndroidX.Compose.foundationLayout) implementation(Dependencies.AndroidX.Compose.lifecycleViewModel) implementation(Dependencies.AndroidX.Compose.material) + implementation(Dependencies.AndroidX.Compose.navigation) implementation(Dependencies.AndroidX.Compose.paging) implementation(Dependencies.AndroidX.Compose.runtime) implementation(Dependencies.AndroidX.Compose.ui) diff --git a/app/src/androidTest/java/dev/msfjarvis/lobsters/BottomNavigationLayoutTest.kt b/app/src/androidTest/java/dev/msfjarvis/lobsters/BottomNavigationLayoutTest.kt new file mode 100644 index 00000000..d5aa53e2 --- /dev/null +++ b/app/src/androidTest/java/dev/msfjarvis/lobsters/BottomNavigationLayoutTest.kt @@ -0,0 +1,115 @@ +package dev.msfjarvis.lobsters + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onChildAt +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import dev.msfjarvis.lobsters.ui.main.LobstersBottomNav +import dev.msfjarvis.lobsters.ui.navigation.Destination +import dev.msfjarvis.lobsters.ui.theme.LobstersTheme +import org.junit.Before +import org.junit.Rule +import org.junit.Test + + +class BottomNavigationLayoutTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setUp() { + composeTestRule.setContent { + LobstersTheme { + var mutableDestination by remember { mutableStateOf(Destination.startDestination) } + + LobstersBottomNav( + currentDestination = mutableDestination, + navigateToDestination = { mutableDestination = it }, + jumpToIndex = {} + ) + } + } + } + + @Test + fun bottomNavItemCountTest() { + // Test to make sure total items are equal to enum objects present in Destination + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildren() + .assertCountEquals(Destination.values().size) + } + + @Test + fun bottomNavItemTest() { + // Check hottest BottomNavItem is rendered correctly + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(0) + .assertTextEquals("Hottest") + .assertContentDescriptionEquals("Hottest") + .assertHasClickAction() + + // Check saved BottomNavItem is rendered correctly + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(1) + .assertTextEquals("Saved") + .assertContentDescriptionEquals("Saved") + .assertHasClickAction() + } + + @Test + fun bottomNavItemSelectedTest() { + // Check hottest BottomNav item is selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(0) + .assertIsSelected() + .assertTextEquals("Hottest") + + // Check saved BottomNav item is not selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(1) + .assertIsNotSelected() + + // Select the saved BottomNav item + composeTestRule.onNodeWithTag("LobstersBottomNav") + .onChildAt(1) + .performClick() + + // Check hottest BottomNav item is not selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(0) + .assertIsNotSelected() + + // Check saved BottomNav item is selected + composeTestRule.onNodeWithTag("LobstersBottomNav") + .assertExists() + .assertIsDisplayed() + .onChildAt(1) + .assertIsSelected() + .assertTextEquals("Saved") + } +} diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt index be83cd7b..74d659e1 100644 --- a/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/main/LobstersApp.kt @@ -1,75 +1,114 @@ package dev.msfjarvis.lobsters.ui.main -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.BackdropScaffold -import androidx.compose.material.BackdropScaffoldDefaults -import androidx.compose.material.BackdropValue +import androidx.compose.material.BottomNavigation +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material.Scaffold import androidx.compose.material.Text -import androidx.compose.material.rememberBackdropScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.KEY_ROUTE +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.navigate +import androidx.navigation.compose.rememberNavController import androidx.paging.compose.collectAsLazyPagingItems -import dev.msfjarvis.lobsters.R +import dev.msfjarvis.lobsters.ui.navigation.Destination import dev.msfjarvis.lobsters.ui.posts.HottestPosts import dev.msfjarvis.lobsters.ui.posts.SavedPosts import dev.msfjarvis.lobsters.ui.viewmodel.LobstersViewModel +import dev.msfjarvis.lobsters.util.IconResource +import kotlinx.coroutines.launch @Composable fun LobstersApp() { val viewModel: LobstersViewModel = viewModel() - val scaffoldState = rememberBackdropScaffoldState(initialValue = BackdropValue.Concealed) - BackdropScaffold( - scaffoldState = scaffoldState, - gesturesEnabled = false, - appBar = { - Spacer(modifier = Modifier.height(BackdropScaffoldDefaults.PeekHeight.times(0.3f))) - Text( - text = stringResource(id = R.string.app_name), - textAlign = TextAlign.Center, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(BackdropScaffoldDefaults.PeekHeight.times(0.3f))) - }, - backLayerContent = { BackgroundLayerContent(viewModel) }, - ) { - ForegroundLayerContent(viewModel) - } -} - -@Composable -fun ForegroundLayerContent( - viewModel: LobstersViewModel, -) { + val coroutineScope = rememberCoroutineScope() + val navController = rememberNavController() val hottestPosts = viewModel.posts.collectAsLazyPagingItems() - val hottestPostsListState = rememberLazyListState() - - HottestPosts( - posts = hottestPosts, - listState = hottestPostsListState, - isPostSaved = viewModel::isPostSaved, - saveAction = viewModel::toggleSave, - ) -} - -@Composable -fun BackgroundLayerContent( - viewModel: LobstersViewModel, -) { val savedPosts by viewModel.savedPosts.collectAsState() - SavedPosts( - posts = savedPosts, - saveAction = viewModel::toggleSave, - ) + val hottestPostsListState = rememberLazyListState() + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = + navBackStackEntry?.arguments?.getString(KEY_ROUTE) ?: Destination.startDestination.route + val currentDestination = Destination.getDestinationFromRoute(currentRoute) + val navigateToDestination: (destination: Destination) -> Unit = { destination -> + navController.navigate(destination.route) { + launchSingleTop = true + popUpTo(navController.graph.startDestination) { inclusive = false } + } + } + val jumpToIndex: (Int) -> Unit = { + coroutineScope.launch { + hottestPostsListState.snapToItemIndex(it) + } + } + + Scaffold( + bottomBar = { + LobstersBottomNav( + currentDestination, + navigateToDestination, + jumpToIndex, + ) + }, + ) { innerPadding -> + NavHost(navController, startDestination = Destination.startDestination.route) { + composable(Destination.Hottest.route) { + HottestPosts( + posts = hottestPosts, + listState = hottestPostsListState, + isPostSaved = viewModel::isPostSaved, + saveAction = viewModel::toggleSave, + modifier = Modifier.padding(bottom = innerPadding.calculateBottomPadding()), + ) + } + composable(Destination.Saved.route) { + SavedPosts( + posts = savedPosts, + saveAction = viewModel::toggleSave, + modifier = Modifier.padding(bottom = innerPadding.calculateBottomPadding()), + ) + } + } + } +} + +@Composable +fun LobstersBottomNav( + currentDestination: Destination, + navigateToDestination: (destination: Destination) -> Unit, + jumpToIndex: (index: Int) -> Unit, +) { + BottomNavigation(modifier = Modifier.testTag("LobstersBottomNav")) { + Destination.values().forEach { screen -> + BottomNavigationItem( + icon = { + IconResource( + resourceId = screen.badgeRes, + contentDescription = stringResource(screen.labelRes), + ) + }, + label = { Text(stringResource(id = screen.labelRes)) }, + selected = currentDestination == screen, + alwaysShowLabels = false, + onClick = { + if (screen != currentDestination) { + navigateToDestination(screen) + } else if (screen.route == Destination.Hottest.route) { + jumpToIndex(0) + } + } + ) + } + } } diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/navigation/Destination.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/navigation/Destination.kt new file mode 100644 index 00000000..44d5708a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/navigation/Destination.kt @@ -0,0 +1,26 @@ +package dev.msfjarvis.lobsters.ui.navigation + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import dev.msfjarvis.lobsters.R + +/** + * Destinations for navigation within the app. + */ +enum class Destination( + val route: String, + @StringRes val labelRes: Int, + @DrawableRes val badgeRes: Int, +) { + Hottest("hottest", R.string.hottest_posts, R.drawable.ic_whatshot_24px), + Saved("saved", R.string.saved_posts, R.drawable.ic_favorite_24px), + ; + + companion object { + val startDestination = Hottest + + fun getDestinationFromRoute(route: String): Destination { + return values().firstOrNull { it.route == route } ?: error("Incorrect route passed") + } + } +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index e36054b1..d31a647c 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -43,6 +43,7 @@ object Dependencies { const val foundationLayout = "androidx.compose.foundation:foundation-layout:$COMPOSE_VERSION" const val lifecycleViewModel = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha01" const val material = "androidx.compose.material:material:$COMPOSE_VERSION" + const val navigation = "androidx.navigation:navigation-compose:1.0.0-alpha07" const val paging = "androidx.paging:paging-compose:1.0.0-alpha07" const val runtime = "androidx.compose.runtime:runtime:$COMPOSE_VERSION" const val ui = "androidx.compose.ui:ui:$COMPOSE_VERSION"