diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 0038a0638..bb993c5c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -20,6 +20,7 @@ import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestin import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHintDestination import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint +import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.trustedDeviceDestination import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination @@ -106,6 +107,9 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { masterPasswordHintDestination( onNavigateBack = { navController.popBackStack() }, ) + trustedDeviceDestination( + onNavigateBack = { navController.popBackStack() }, + ) twoFactorLoginDestination( onNavigateBack = { navController.popBackStack() }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceNavigation.kt new file mode 100644 index 000000000..2df59ea7c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceNavigation.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.ui.auth.feature.trusteddevice + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val TRUSTED_DEVICE_ROUTE: String = "trusted_device" + +/** + * Add the Trusted Device Screen to the nav graph. + */ +fun NavGraphBuilder.trustedDeviceDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = TRUSTED_DEVICE_ROUTE, + ) { + TrustedDeviceScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the Trusted Device Screen. + */ +fun NavController.navigateToTrustedDevice(navOptions: NavOptions? = null) { + this.navigate(TRUSTED_DEVICE_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceScreen.kt new file mode 100644 index 000000000..f99ac1e73 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceScreen.kt @@ -0,0 +1,70 @@ +package com.x8bit.bitwarden.ui.auth.feature.trusteddevice + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.handlers.TrustedDeviceHandlers +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold + +/** + * The top level composable for the Reset Password screen. + */ +@Composable +fun TrustedDeviceScreen( + viewModel: TrustedDeviceViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val handlers = remember(viewModel) { TrustedDeviceHandlers.create(viewModel = viewModel) } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + TrustedDeviceEvent.NavigateBack -> onNavigateBack() + } + } + + TrustedDeviceScaffold( + state = state, + handlers = handlers, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TrustedDeviceScaffold( + state: TrustedDeviceState, + handlers: TrustedDeviceHandlers, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.log_in_initiated), + scrollBehavior = scrollBehavior, + navigationIcon = NavigationIcon( + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = handlers.onBackClick, + ), + ) + }, + ) { innerPadding -> + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceViewModel.kt new file mode 100644 index 000000000..a2b37929f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceViewModel.kt @@ -0,0 +1,53 @@ +package com.x8bit.bitwarden.ui.auth.feature.trusteddevice + +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Manages application state for the Trusted Device screen. + */ +@HiltViewModel +class TrustedDeviceViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : BaseViewModel<TrustedDeviceState, TrustedDeviceEvent, TrustedDeviceAction>( + initialState = savedStateHandle[KEY_STATE] ?: TrustedDeviceState, +) { + override fun handleAction(action: TrustedDeviceAction) { + when (action) { + TrustedDeviceAction.BackClick -> handleBackClick() + } + } + + private fun handleBackClick() { + sendEvent(TrustedDeviceEvent.NavigateBack) + } +} + +/** + * Models the state for the Trusted Device screen. + */ +data object TrustedDeviceState + +/** + * Models events for the Trusted Device screen. + */ +sealed class TrustedDeviceEvent { + /** + * Navigates back. + */ + data object NavigateBack : TrustedDeviceEvent() +} + +/** + * Models actions for the Trusted Device screen. + */ +sealed class TrustedDeviceAction { + /** + * User clicked back button. + */ + data object BackClick : TrustedDeviceAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/handlers/TrustedDeviceHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/handlers/TrustedDeviceHandlers.kt new file mode 100644 index 000000000..cb8cab270 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/handlers/TrustedDeviceHandlers.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.ui.auth.feature.trusteddevice.handlers + +import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.TrustedDeviceAction +import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.TrustedDeviceViewModel + +/** + * A collection of handler functions for managing actions within the context of the Trusted Device + * Screen. + */ +data class TrustedDeviceHandlers( + val onBackClick: () -> Unit, +) { + companion object { + /** + * Creates an instance of [TrustedDeviceHandlers] by binding actions to the provided + * [TrustedDeviceViewModel]. + */ + fun create(viewModel: TrustedDeviceViewModel): TrustedDeviceHandlers = + TrustedDeviceHandlers( + onBackClick = { viewModel.trySendAction(TrustedDeviceAction.BackClick) }, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceScreenTest.kt new file mode 100644 index 000000000..f12c3ab09 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceScreenTest.kt @@ -0,0 +1,40 @@ +package com.x8bit.bitwarden.ui.auth.feature.trusteddevice + +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue + +class TrustedDeviceScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled: Boolean = false + + private val mutableEventFlow = bufferedMutableSharedFlow<TrustedDeviceEvent>() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + val viewModel = mockk<TrustedDeviceViewModel>(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + TrustedDeviceScreen( + viewModel = viewModel, + onNavigateBack = { onNavigateBackCalled = true }, + ) + } + } + + @Test + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(TrustedDeviceEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } +} + +private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceViewModelTest.kt new file mode 100644 index 000000000..7a23928f6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/trusteddevice/TrustedDeviceViewModelTest.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.ui.auth.feature.trusteddevice + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TrustedDeviceViewModelTest : BaseViewModelTest() { + + @Test + fun `on BackClick emits NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(TrustedDeviceAction.BackClick) + assertEquals(TrustedDeviceEvent.NavigateBack, awaitItem()) + } + } + + private fun createViewModel( + state: TrustedDeviceState? = null, + ): TrustedDeviceViewModel = + TrustedDeviceViewModel( + savedStateHandle = SavedStateHandle().apply { set("state", state) }, + ) +}