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 index f99ac1e73..1e7e04429 100644 --- 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 @@ -1,7 +1,19 @@ package com.x8bit.bitwarden.ui.auth.feature.trusteddevice +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -9,8 +21,13 @@ 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R @@ -18,7 +35,11 @@ import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.handlers.TrustedDeviceH 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.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.text.BitwardenClickableText +import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch /** * The top level composable for the Reset Password screen. @@ -31,9 +52,15 @@ fun TrustedDeviceScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val handlers = remember(viewModel) { TrustedDeviceHandlers.create(viewModel = viewModel) } + val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> when (event) { TrustedDeviceEvent.NavigateBack -> onNavigateBack() + is TrustedDeviceEvent.ShowToast -> { + Toast + .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) + .show() + } } } @@ -43,6 +70,7 @@ fun TrustedDeviceScreen( ) } +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TrustedDeviceScaffold( @@ -66,5 +94,94 @@ private fun TrustedDeviceScaffold( ) }, ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenSwitch( + label = stringResource(id = R.string.remember_this_device), + description = stringResource(id = R.string.turn_off_using_public_device), + isChecked = state.isRemembered, + onCheckedChange = handlers.onRememberToggle, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + BitwardenFilledButton( + label = stringResource(id = R.string.approve_with_my_other_device), + onClick = handlers.onApproveWithDeviceClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = R.string.request_admin_approval), + onClick = handlers.onApproveWithAdminClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = R.string.approve_with_master_password), + onClick = handlers.onApproveWithPasswordClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource( + id = R.string.logging_in_as_x_on_y, + state.emailAddress, + state.environmentLabel, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .semantics { testTag = "LoggingInAsLabel" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + BitwardenClickableText( + label = stringResource(id = R.string.not_you), + onClick = handlers.onNotYouButtonClick, + style = MaterialTheme.typography.labelLarge, + innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), + modifier = Modifier.semantics { testTag = "NotYouLabel" }, + ) + + Spacer(modifier = Modifier.navigationBarsPadding()) + } } } + +@Preview +@Composable +private fun TrustedDeviceScaffold_preview() { + TrustedDeviceScaffold( + state = TrustedDeviceState( + isRemembered = false, + emailAddress = "email@bitwarden.com", + environmentLabel = "vault.bitwarden.pw", + ), + handlers = TrustedDeviceHandlers( + onBackClick = {}, + onRememberToggle = {}, + onApproveWithAdminClick = {}, + onApproveWithDeviceClick = {}, + onApproveWithPasswordClick = {}, + onNotYouButtonClick = {}, + ), + ) +} 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 index a2b37929f..838a16472 100644 --- 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 @@ -1,8 +1,13 @@ package com.x8bit.bitwarden.ui.auth.feature.trusteddevice +import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject private const val KEY_STATE = "state" @@ -14,23 +19,58 @@ private const val KEY_STATE = "state" class TrustedDeviceViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : BaseViewModel<TrustedDeviceState, TrustedDeviceEvent, TrustedDeviceAction>( - initialState = savedStateHandle[KEY_STATE] ?: TrustedDeviceState, + initialState = savedStateHandle[KEY_STATE] + ?: TrustedDeviceState( + emailAddress = "", + environmentLabel = "", + isRemembered = false, + ), ) { override fun handleAction(action: TrustedDeviceAction) { when (action) { TrustedDeviceAction.BackClick -> handleBackClick() + is TrustedDeviceAction.RememberToggle -> handleRememberToggle(action) + TrustedDeviceAction.ApproveWithAdminClick -> handleApproveWithAdminClick() + TrustedDeviceAction.ApproveWithDeviceClick -> handleApproveWithDeviceClick() + TrustedDeviceAction.ApproveWithPasswordClick -> handleApproveWithPasswordClick() + TrustedDeviceAction.NotYouClick -> handleNotYouClick() } } private fun handleBackClick() { sendEvent(TrustedDeviceEvent.NavigateBack) } + + private fun handleRememberToggle(action: TrustedDeviceAction.RememberToggle) { + mutableStateFlow.update { it.copy(isRemembered = action.isRemembered) } + } + + private fun handleApproveWithAdminClick() { + sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText())) + } + + private fun handleApproveWithDeviceClick() { + sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText())) + } + + private fun handleApproveWithPasswordClick() { + sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText())) + } + + private fun handleNotYouClick() { + sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText())) + } } /** * Models the state for the Trusted Device screen. */ -data object TrustedDeviceState +@Parcelize +data class TrustedDeviceState( + val emailAddress: String, + val environmentLabel: String, + val isRemembered: Boolean, +) : Parcelable /** * Models events for the Trusted Device screen. @@ -40,6 +80,11 @@ sealed class TrustedDeviceEvent { * Navigates back. */ data object NavigateBack : TrustedDeviceEvent() + + /** + * Displays the [message] as a toast. + */ + data class ShowToast(val message: Text) : TrustedDeviceEvent() } /** @@ -50,4 +95,31 @@ sealed class TrustedDeviceAction { * User clicked back button. */ data object BackClick : TrustedDeviceAction() + + /** + * User toggled the remember device switch. + */ + data class RememberToggle( + val isRemembered: Boolean, + ) : TrustedDeviceAction() + + /** + * User clicked the "Approve with my other device" button. + */ + data object ApproveWithDeviceClick : TrustedDeviceAction() + + /** + * User clicked the "Request admin approval" button. + */ + data object ApproveWithAdminClick : TrustedDeviceAction() + + /** + * User clicked the "Approve with master password" button. + */ + data object ApproveWithPasswordClick : TrustedDeviceAction() + + /** + * Indicates that the "Not you?" text was clicked. + */ + data object NotYouClick : 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 index cb8cab270..3c93ac4a4 100644 --- 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 @@ -9,6 +9,11 @@ import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.TrustedDeviceViewModel */ data class TrustedDeviceHandlers( val onBackClick: () -> Unit, + val onRememberToggle: (Boolean) -> Unit, + val onApproveWithDeviceClick: () -> Unit, + val onApproveWithAdminClick: () -> Unit, + val onApproveWithPasswordClick: () -> Unit, + val onNotYouButtonClick: () -> Unit, ) { companion object { /** @@ -18,6 +23,21 @@ data class TrustedDeviceHandlers( fun create(viewModel: TrustedDeviceViewModel): TrustedDeviceHandlers = TrustedDeviceHandlers( onBackClick = { viewModel.trySendAction(TrustedDeviceAction.BackClick) }, + onRememberToggle = { + viewModel.trySendAction(TrustedDeviceAction.RememberToggle(it)) + }, + onApproveWithDeviceClick = { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithDeviceClick) + }, + onApproveWithAdminClick = { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithAdminClick) + }, + onApproveWithPasswordClick = { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithPasswordClick) + }, + onNotYouButtonClick = { + viewModel.trySendAction(TrustedDeviceAction.NotYouClick) + }, ) } } 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 index 712d91793..fe3ca99c6 100644 --- 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 @@ -1,13 +1,19 @@ package com.x8bit.bitwarden.ui.auth.feature.trusteddevice +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.Before import org.junit.Test import org.junit.jupiter.api.Assertions.assertTrue @@ -46,6 +52,110 @@ class TrustedDeviceScreenTest : BaseComposeTest() { viewModel.trySendAction(TrustedDeviceAction.BackClick) } } + + @Test + fun `on remember toggle changed should send RememberToggle`() { + composeTestRule + .onNodeWithText("Remember this device") + .performScrollTo() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(TrustedDeviceAction.RememberToggle(true)) + } + } + + @Test + fun `on approve with device clicked should send ApproveWithDeviceClick`() { + composeTestRule + .onNodeWithText("Approve with my other device") + .performScrollTo() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithDeviceClick) + } + } + + @Test + fun `on approve with admin clicked should send ApproveWithAdminClick`() { + composeTestRule + .onNodeWithText("Request admin approval") + .performScrollTo() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithAdminClick) + } + } + + @Test + fun `on approve with master password clicked should send ApproveWithPasswordClick`() { + composeTestRule + .onNodeWithText("Approve with master password") + .performScrollTo() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithPasswordClick) + } + } + + @Test + fun `on not you clicked should send NotYouClick`() { + composeTestRule + .onNodeWithText("Not you?") + .performScrollTo() + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(TrustedDeviceAction.NotYouClick) + } + } + + @Test + fun `remember this device toggle should update according to state`() { + mutableStateFlow.update { DEFAULT_STATE } + + composeTestRule + .onNodeWithText("Remember this device") + .performScrollTo() + .assertIsOff() + + mutableStateFlow.update { DEFAULT_STATE.copy(isRemembered = true) } + + composeTestRule + .onNodeWithText("Remember this device") + .performScrollTo() + .assertIsOn() + } + + @Test + fun `email and environment label should update according to state`() { + mutableStateFlow.update { DEFAULT_STATE } + + composeTestRule + .onNodeWithText("Logging in as bitwarden@email.com on vault.test.pw") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Logging in as email@bitwarden.com on vault.bitwarden.pw") + .performScrollTo() + .assertIsDisplayed() + + mutableStateFlow.update { + DEFAULT_STATE.copy( + emailAddress = "bitwarden@email.com", + environmentLabel = "vault.test.pw", + ) + } + + composeTestRule + .onNodeWithText("Logging in as email@bitwarden.com on vault.bitwarden.pw") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Logging in as bitwarden@email.com on vault.test.pw") + .performScrollTo() + .assertIsDisplayed() + } } -private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState +private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState( + emailAddress = "email@bitwarden.com", + environmentLabel = "vault.bitwarden.pw", + isRemembered = false, +) 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 index 7a23928f6..eb58eb26d 100644 --- 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 @@ -3,6 +3,7 @@ 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 com.x8bit.bitwarden.ui.platform.base.util.asText import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -19,6 +20,59 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() { } } + @Test + fun `on RememberToggle updates the isRemembered state`() = runTest { + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.trySendAction(TrustedDeviceAction.RememberToggle(isRemembered = true)) + assertEquals(DEFAULT_STATE.copy(isRemembered = true), awaitItem()) + viewModel.trySendAction(TrustedDeviceAction.RememberToggle(isRemembered = false)) + assertEquals(DEFAULT_STATE.copy(isRemembered = false), awaitItem()) + } + } + + @Test + fun `on ApproveWithAdminClick emits ShowToast`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithAdminClick) + assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + } + } + + @Test + fun `on ApproveWithDeviceClick emits ShowToast`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithDeviceClick) + assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + } + } + + @Test + fun `on ApproveWithPasswordClick emits ShowToast`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(TrustedDeviceAction.ApproveWithPasswordClick) + assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + } + } + + @Test + fun `on NotYouClick emits ShowToast`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(TrustedDeviceAction.NotYouClick) + assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + } + } + private fun createViewModel( state: TrustedDeviceState? = null, ): TrustedDeviceViewModel = @@ -26,3 +80,9 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle().apply { set("state", state) }, ) } + +private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState( + emailAddress = "", + environmentLabel = "", + isRemembered = false, +)