From 116d48d8ac92a3ab8682fe5acaeb7ac9938739e8 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Fri, 25 Aug 2023 08:54:18 -0500 Subject: [PATCH] BIT-144: Setup root level app navigation (#8) --- app/build.gradle.kts | 1 + .../java/com/x8bit/bitwarden/MainActivity.kt | 21 +--- .../ui/components/PlaceholderComposable.kt | 27 +++++ .../ui/feature/rootnav/RootNavScreen.kt | 110 ++++++++++++++++++ .../ui/feature/rootnav/RootNavViewModel.kt | 44 +++++++ .../example/MainDispatcherExtension.kt | 45 +++++++ .../bitwarden/example/ui/BaseViewModelTest.kt | 9 ++ .../feature/rootnav/RootNavViewModelTests.kt | 26 +++++ gradle/libs.versions.toml | 3 +- 9 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/components/PlaceholderComposable.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/example/MainDispatcherExtension.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/example/ui/BaseViewModelTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e994582f1..7c7866e69 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.navigation.compose) ksp(libs.androidx.room.compiler) diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 93fdfff88..6624ea77b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -3,13 +3,7 @@ package com.x8bit.bitwarden import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.x8bit.bitwarden.ui.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.feature.rootnav.RootNavScreen import dagger.hilt.android.AndroidEntryPoint /** @@ -19,17 +13,6 @@ import dagger.hilt.android.AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { - BitwardenTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Text( - text = stringResource(id = R.string.app_name), - ) - } - } - } + setContent { RootNavScreen() } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/components/PlaceholderComposable.kt b/app/src/main/java/com/x8bit/bitwarden/ui/components/PlaceholderComposable.kt new file mode 100644 index 000000000..4e49f0eda --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/components/PlaceholderComposable.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * Temporary composable that can be used as a navigation placeholder. + */ +@Composable +fun PlaceholderComposable( + text: String = "Placeholder Composable" +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.Center, + ) { + Text(text = text) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt new file mode 100644 index 000000000..18026c321 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavScreen.kt @@ -0,0 +1,110 @@ +package com.x8bit.bitwarden.ui.feature.rootnav + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.x8bit.bitwarden.ui.components.PlaceholderComposable + +/** + * Controls root level [NavHost] for the app. + */ +@Composable +fun RootNavScreen( + viewModel: RootNavViewModel = viewModel() +) { + val navController = rememberNavController() + val state by viewModel.state.collectAsStateWithLifecycle() + + NavHost( + navController = navController, + startDestination = SplashRoute, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { + splashDestinations() + loginDestinations() + } + + // When state changes, navigate to different root navigation state + when (state) { + RootNavState.Login -> navController.navigateToLoginAsRoot() + RootNavState.Splash -> navController.navigateToSplashAsRoot() + } +} + +/** + * The functions below should be moved to their respective feature packages once they exist. + * + * For an example of how to setup these nav extensions, see NIA project. + */ + +/** + * TODO: move to splash package (BIT-147) + */ +private const val SplashRoute = "splash" + +/** + * Add splash destinations to the nav graph. + * + * TODO: move to splash package (BIT-147) + */ +private fun NavGraphBuilder.splashDestinations() { + composable(SplashRoute) { + PlaceholderComposable(text = "Splash") + } +} + +/** + * Navigate to the splash screen. Note this will only work if splash destination was added + * via [splashDestinations]. + * + * TODO: move to splash package (BIT-147) + * + */ +private fun NavController.navigateToSplashAsRoot() { + navigate(SplashRoute) { + // When changing root navigation state, pop everything else off the back stack: + popUpTo(graph.id) { + inclusive = true + } + } +} + +/** + * TODO move to login package(BIT-146) + */ +private val LoginRoute = "login" + +/** + * Add login destinations to the nav graph. + * + * TODO: move to login package (BIT-146) + */ +private fun NavGraphBuilder.loginDestinations() { + composable(LoginRoute) { + PlaceholderComposable(text = "Login") + } +} + +/** + * Navigate to the splash screen. Note this will only work if login destination was added + * via [loginDestinations]. + * + * TODO: move to login package (BIT-146) + */ +private fun NavController.navigateToLoginAsRoot() { + navigate(LoginRoute) { + // When changing root navigation state, pop everything else off the back stack: + popUpTo(graph.id) { + inclusive = true + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt new file mode 100644 index 000000000..e336d07d3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/feature/rootnav/RootNavViewModel.kt @@ -0,0 +1,44 @@ +package com.x8bit.bitwarden.ui.feature.rootnav + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Manages root level navigation state of the application. + */ +@HiltViewModel +class RootNavViewModel @Inject constructor() : ViewModel() { + + private val _state = MutableStateFlow(RootNavState.Splash) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + delay(1000) + _state.value = RootNavState.Login + } + } + +} + +/** + * Models state of the root level navigation of the app. + */ +sealed class RootNavState { + /** + * Show the login screen. + */ + data object Login : RootNavState() + + /** + * Show the splash screen. + */ + data object Splash : RootNavState() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/example/MainDispatcherExtension.kt b/app/src/test/java/com/x8bit/bitwarden/example/MainDispatcherExtension.kt new file mode 100644 index 000000000..b92f25d90 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/MainDispatcherExtension.kt @@ -0,0 +1,45 @@ +package com.x8bit.bitwarden.example + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterAllCallback +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeAllCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * JUnit 5 Extension for automatically setting a [testDispatcher] as the "main" dispatcher. + * + * Note that this may be used as a normal class property with [RegisterExtension] or may be applied + * directly to a test class using [ExtendWith]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherExtension( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : AfterAllCallback, + AfterEachCallback, + BeforeAllCallback, + BeforeEachCallback { + override fun afterAll(context: ExtensionContext?) { + Dispatchers.resetMain() + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } + + override fun beforeAll(context: ExtensionContext?) { + Dispatchers.setMain(testDispatcher) + } + + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(testDispatcher) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ui/BaseViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/example/ui/BaseViewModelTest.kt new file mode 100644 index 000000000..1c8ba612a --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/ui/BaseViewModelTest.kt @@ -0,0 +1,9 @@ +package com.x8bit.bitwarden.example.ui + +import com.x8bit.bitwarden.example.MainDispatcherExtension +import org.junit.jupiter.api.extension.RegisterExtension + +abstract class BaseViewModelTest { + @RegisterExtension + protected open val mainDispatcherExtension = MainDispatcherExtension() +} diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt new file mode 100644 index 000000000..a5f83e145 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/ui/feature/rootnav/RootNavViewModelTests.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.example.ui.feature.rootnav + +import app.cash.turbine.test +import com.x8bit.bitwarden.example.ui.BaseViewModelTest +import com.x8bit.bitwarden.ui.feature.rootnav.RootNavState +import com.x8bit.bitwarden.ui.feature.rootnav.RootNavViewModel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class RootNavViewModelTests : BaseViewModelTest() { + + @Test + fun `initial state should be splash`() { + val viewModel = RootNavViewModel() + assert(viewModel.state.value is RootNavState.Splash) + } + + @Test + fun `state should move from splash to login`() = runTest { + val viewModel = RootNavViewModel() + viewModel.state.test { + assert(awaitItem() is RootNavState.Splash) + assert(awaitItem() is RootNavState.Login) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3edb81270..4c9b8cbff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ androidxComposeBom = "2023.06.01" androidxCore = "1.10.1" androidxHiltNavigationCompose = "1.0.0" androidxLifecycle = "2.6.1" -androidxNavigation = "2.6.0" +androidxNavigation = "2.7.0" androidxRoom = "2.5.2" detekt = "1.23.1" firebaseBom = "32.2.2" @@ -51,6 +51,7 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-runtime-compose ={ module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidxRoom" }