BIT-144: Setup root level app navigation (#8)

This commit is contained in:
Andrew Haisting 2023-08-25 08:54:18 -05:00 committed by Álison Fernandes
parent cd204b9b11
commit 116d48d8ac
9 changed files with 266 additions and 20 deletions

View file

@ -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)

View file

@ -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() }
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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>(RootNavState.Splash)
val state: StateFlow<RootNavState> = _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()
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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" }