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 210f7fd6f..7417fe888 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 @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDest import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.navigateToEnterpriseSignOn import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment +import com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink.expiredRegistrationLinkDestination import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination import com.x8bit.bitwarden.ui.auth.feature.landing.navigateToLanding @@ -189,6 +190,23 @@ fun NavGraphBuilder.authGraph( navController.popUpToCompleteRegistration() }, ) + expiredRegistrationLinkDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToStartRegistration = { + navController.navigateToStartRegistration( + navOptions = navOptions { + popUpTo(LANDING_ROUTE) + }, + ) + }, + onNavigateToLogin = { + navController.navigateToLanding( + navOptions = navOptions { + popUpTo(LANDING_ROUTE) + }, + ) + }, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkNavigation.kt new file mode 100644 index 000000000..f3b87691d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkNavigation.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions + +private const val EXPIRED_REGISTRATION_LINK_ROUTE = "expired_registration_link" + +/** + * Navigate to the expired registration link screen. + */ +fun NavController.navigateToExpiredRegistrationLinkScreen(navOptions: NavOptions? = null) { + this.navigate(route = EXPIRED_REGISTRATION_LINK_ROUTE, navOptions = navOptions) +} + +/** + * Add the expired registration link screen to the nav graph. + */ +fun NavGraphBuilder.expiredRegistrationLinkDestination( + onNavigateBack: () -> Unit, + onNavigateToStartRegistration: () -> Unit, + onNavigateToLogin: () -> Unit, +) { + composableWithPushTransitions( + route = EXPIRED_REGISTRATION_LINK_ROUTE, + ) { + ExpiredRegistrationLinkScreen( + onNavigateBack = onNavigateBack, + onNavigateToStartRegistration = onNavigateToStartRegistration, + onNavigateToLogin = onNavigateToLogin, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt new file mode 100644 index 000000000..38557b848 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt @@ -0,0 +1,152 @@ +package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin +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.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * Top level screen component for the ExpiredRegistrationLink screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpiredRegistrationLinkScreen( + onNavigateBack: () -> Unit, + onNavigateToLogin: () -> Unit, + onNavigateToStartRegistration: () -> Unit, + viewModel: ExpiredRegistrationLinkViewModel = hiltViewModel(), +) { + EventsEffect(viewModel = viewModel) { event -> + when (event) { + ExpiredRegistrationLinkEvent.NavigateBack -> onNavigateBack() + ExpiredRegistrationLinkEvent.NavigateToLogin -> onNavigateToLogin() + ExpiredRegistrationLinkEvent.NavigateToStartRegistration -> { + onNavigateToStartRegistration() + } + } + } + + BitwardenScaffold( + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.create_account), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + navigationIcon = NavigationIcon( + navigationIcon = rememberVectorPainter(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { + viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) + } + }, + ), + ) + }, + ) { innerPadding -> + ExpiredRegistrationLinkContent( + onNavigateToLogin = remember(viewModel) { + { + viewModel.trySendAction(ExpiredRegistrationLinkAction.GoToLoginClicked) + } + }, + onNavigateToStartRegistration = remember(viewModel) { + { + viewModel.trySendAction( + ExpiredRegistrationLinkAction.RestartRegistrationClicked, + ) + } + }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } +} + +@Composable +private fun ExpiredRegistrationLinkContent( + onNavigateToLogin: () -> Unit, + onNavigateToStartRegistration: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.expired_link), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.please_restart_registration_or_try_logging_in), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(24.dp)) + BitwardenFilledButton( + label = stringResource(R.string.restart_registration), + onClick = onNavigateToStartRegistration, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = R.string.log_in_verb), + onClick = onNavigateToLogin, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExpiredRegistrationLinkScreen_preview() { + BitwardenTheme { + ExpiredRegistrationLinkContent( + onNavigateToLogin = {}, + onNavigateToStartRegistration = {}, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt new file mode 100644 index 000000000..36c28e75a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt @@ -0,0 +1,75 @@ +package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink + +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import javax.inject.Inject + +/** + * View model for the [ExpiredRegistrationLinkScreen]. + */ +class ExpiredRegistrationLinkViewModel @Inject constructor() : + BaseViewModel( + initialState = Unit, + ) { + override fun handleAction(action: ExpiredRegistrationLinkAction) { + when (action) { + ExpiredRegistrationLinkAction.CloseClicked -> handleCloseClicked() + ExpiredRegistrationLinkAction.GoToLoginClicked -> handleGoToLoginClicked() + ExpiredRegistrationLinkAction.RestartRegistrationClicked -> { + handleRestartRegistrationClicked() + } + } + } + + private fun handleRestartRegistrationClicked() { + sendEvent(ExpiredRegistrationLinkEvent.NavigateToStartRegistration) + } + + private fun handleGoToLoginClicked() { + sendEvent(ExpiredRegistrationLinkEvent.NavigateToLogin) + } + + private fun handleCloseClicked() { + sendEvent(ExpiredRegistrationLinkEvent.NavigateBack) + } +} + +/** + * Model the events that can be sent from the [ExpiredRegistrationLinkViewModel]. + */ +sealed class ExpiredRegistrationLinkEvent { + + /** + * Models event to navigate back to the previous screen. + */ + data object NavigateBack : ExpiredRegistrationLinkEvent() + + /** + * Models event to navigate to the login screen. + */ + data object NavigateToLogin : ExpiredRegistrationLinkEvent() + + /** + * Models event to navigate to the start registration screen. + */ + data object NavigateToStartRegistration : ExpiredRegistrationLinkEvent() +} + +/** + * Models the actions that can be handled by the [ExpiredRegistrationLinkViewModel]. + */ +sealed class ExpiredRegistrationLinkAction { + /** + * Indicates the close button was clicked. + */ + data object CloseClicked : ExpiredRegistrationLinkAction() + + /** + * Indicated the restart registration button was clicked. + */ + data object RestartRegistrationClicked : ExpiredRegistrationLinkAction() + + /** + * Indicated the login button was clicked. + */ + data object GoToLoginClicked : ExpiredRegistrationLinkAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 305cc791e..374a328ed 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration +import com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink.navigateToExpiredRegistrationLinkScreen import com.x8bit.bitwarden.ui.auth.feature.removepassword.REMOVE_PASSWORD_ROUTE import com.x8bit.bitwarden.ui.auth.feature.removepassword.navigateToRemovePassword import com.x8bit.bitwarden.ui.auth.feature.removepassword.removePasswordDestination @@ -95,6 +96,7 @@ fun RootNavScreen( RootNavState.Auth, is RootNavState.CompleteOngoingRegistration, RootNavState.AuthWithWelcome, + RootNavState.ExpiredRegistrationLink, -> AUTH_GRAPH_ROUTE RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE @@ -156,6 +158,11 @@ fun RootNavScreen( ) } + RootNavState.ExpiredRegistrationLink -> { + navController.navigateToAuthGraph(rootNavOptions) + navController.navigateToExpiredRegistrationLinkScreen() + } + RootNavState.RemovePassword -> navController.navigateToRemovePassword(rootNavOptions) RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions) RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 2fd8a02c6..dd2b5f831 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -302,6 +302,12 @@ sealed class RootNavState : Parcelable { */ @Parcelize data object VaultUnlockedForAuthRequest : RootNavState() + + /** + * App should show the expired registration link screen. + */ + @Parcelize + data object ExpiredRegistrationLink : RootNavState() } /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bee1042bf..be34cd186 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ Invalid PIN. Try again. Launch Log In + Log in Login Log out Are you sure you want to log out? @@ -988,4 +989,7 @@ Do you want to switch to this account? Choose your master password Choose a unique and strong password to keep your information safe. %1$s characters + Expired link + Please restart registration or try logging in. You may already have an account. + Restart registration diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt new file mode 100644 index 000000000..5d136fa9e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt @@ -0,0 +1,82 @@ +package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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 io.mockk.verify +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ExpiredRegistrationLinkScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + private var onNavigateToLoginCalled = false + private var onNavigateToStartRegistrationCalled = false + + private val mutableEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + ExpiredRegistrationLinkScreen( + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToLogin = { onNavigateToLoginCalled = true }, + onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `CloseClicked sends NavigateBack action`() { + composeTestRule + .onNodeWithContentDescription("Close") + .performClick() + + verify { viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) } + } + + @Test + fun `RestartRegistrationClicked sends RestartRegistrationClicked action`() { + composeTestRule + .onNodeWithText("Restart registration") + .performClick() + + verify { viewModel.trySendAction(ExpiredRegistrationLinkAction.RestartRegistrationClicked) } + } + + @Test + fun `GoToLoginClicked sends GoToLoginClicked action`() { + composeTestRule + .onNodeWithText("Log in") + .performClick() + + verify { viewModel.trySendAction(ExpiredRegistrationLinkAction.GoToLoginClicked) } + } + + @Test + fun `NavigateBack event invokes onNavigateBack`() { + mutableEventFlow.tryEmit(ExpiredRegistrationLinkEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `NavigateToLogin event invokes onNavigateToLogin`() { + mutableEventFlow.tryEmit(ExpiredRegistrationLinkEvent.NavigateToLogin) + assertTrue(onNavigateToLoginCalled) + } + + @Test + fun `NavigateToStartRegistration event invokes onNavigateToStartRegistration`() { + mutableEventFlow.tryEmit(ExpiredRegistrationLinkEvent.NavigateToStartRegistration) + assertTrue(onNavigateToStartRegistrationCalled) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt new file mode 100644 index 000000000..16e10bffc --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink + +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 ExpiredRegistrationLinkViewModelTest : BaseViewModelTest() { + + @Test + fun `CloseClicked sends NavigateBack event`() = runTest { + val viewModel = ExpiredRegistrationLinkViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) + assertEquals(ExpiredRegistrationLinkEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `RestartRegistrationClicked sends NavigateToStartRegistration event`() = runTest { + val viewModel = ExpiredRegistrationLinkViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ExpiredRegistrationLinkAction.RestartRegistrationClicked) + assertEquals(ExpiredRegistrationLinkEvent.NavigateToStartRegistration, awaitItem()) + } + } + + @Test + fun `GoToLoginClicked sends NavigateToLogin event`() = runTest { + val viewModel = ExpiredRegistrationLinkViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ExpiredRegistrationLinkAction.GoToLoginClicked) + assertEquals(ExpiredRegistrationLinkEvent.NavigateToLogin, awaitItem()) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 82c30db77..9736a2243 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -98,6 +98,14 @@ class RootNavScreenTest : BaseComposeTest() { ) } + // Make sure navigating to expired registration link route works as expected: + rootNavStateFlow.value = RootNavState.ExpiredRegistrationLink + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "expired_registration_link", + ) + } + // Make sure navigating to vault locked works as expected: rootNavStateFlow.value = RootNavState.VaultLocked composeTestRule.runOnIdle {