diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 7e2c7540cf..8e370f0ec9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -26,19 +27,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.net.toUri 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.IntentHandler +import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography /** * Displays the account security screen. @@ -58,6 +64,10 @@ fun AccountSecurityScreen( when (event) { AccountSecurityEvent.NavigateBack -> onNavigateBack() + AccountSecurityEvent.NavigateToFingerprintPhrase -> { + intentHandler.launchUri("http://bitwarden.com/help/fingerprint-phrase".toUri()) + } + is AccountSecurityEvent.ShowToast -> { Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() } @@ -74,6 +84,16 @@ fun AccountSecurityScreen( }, ) + AccountSecurityDialog.FingerprintPhrase -> FingerPrintPhraseDialog( + fingerprintPhrase = state.fingerprintPhrase, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.DismissDialog) } + }, + onLearnMore = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.FingerPrintLearnMoreClick) } + }, + ) + AccountSecurityDialog.SessionTimeoutAction -> SessionTimeoutActionDialog( selectedSessionTimeoutAction = state.sessionTimeoutAction, onDismissRequest = remember(viewModel) { @@ -276,6 +296,55 @@ private fun ConfirmLogoutDialog( ) } +@Composable +private fun FingerPrintPhraseDialog( + fingerprintPhrase: Text, + onDismissRequest: () -> Unit, + onLearnMore: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + BitwardenTextButton( + label = stringResource(id = R.string.close), + onClick = onDismissRequest, + ) + }, + confirmButton = { + BitwardenTextButton( + label = stringResource(id = R.string.learn_more), + onClick = onLearnMore, + ) + }, + title = { + Text( + text = stringResource(id = R.string.fingerprint_phrase), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.fillMaxWidth(), + ) + }, + text = { + Column { + Text( + text = stringResource(id = R.string.your_accounts_fingerprint), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = fingerprintPhrase(), + color = LocalNonMaterialColors.current.fingerprint, + style = LocalNonMaterialTypography.current.fingerprint, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) +} + @Composable private fun SessionTimeoutActionDialog( selectedSessionTimeoutAction: SessionTimeoutAction, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index 6be713b1cf..4425069879 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -29,6 +29,7 @@ class AccountSecurityViewModel @Inject constructor( initialState = savedStateHandle[KEY_STATE] ?: AccountSecurityState( dialog = null, + fingerprintPhrase = "fingerprint-placeholder".asText(), isApproveLoginRequestsEnabled = false, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = false, @@ -50,6 +51,7 @@ class AccountSecurityViewModel @Inject constructor( AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick() AccountSecurityAction.DeleteAccountClick -> handleDeleteAccountClick() AccountSecurityAction.DismissDialog -> handleDismissDialog() + AccountSecurityAction.FingerPrintLearnMoreClick -> handleFingerPrintLearnMoreClick() AccountSecurityAction.LockNowClick -> handleLockNowClick() is AccountSecurityAction.LoginRequestToggle -> handleLoginRequestToggle(action) AccountSecurityAction.LogoutClick -> handleLogoutClick() @@ -69,8 +71,7 @@ class AccountSecurityViewModel @Inject constructor( } private fun handleAccountFingerprintPhraseClick() { - // TODO BIT-470: Display fingerprint phrase - sendEvent(AccountSecurityEvent.ShowToast("Display fingerprint phrase.".asText())) + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) } } private fun handleBackClick() = sendEvent(AccountSecurityEvent.NavigateBack) @@ -94,6 +95,10 @@ class AccountSecurityViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialog = null) } } + private fun handleFingerPrintLearnMoreClick() { + sendEvent(AccountSecurityEvent.NavigateToFingerprintPhrase) + } + private fun handleLockNowClick() { // TODO BIT-467: Lock the app sendEvent(AccountSecurityEvent.ShowToast("Lock the app.".asText())) @@ -162,6 +167,7 @@ class AccountSecurityViewModel @Inject constructor( @Parcelize data class AccountSecurityState( val dialog: AccountSecurityDialog?, + val fingerprintPhrase: Text, val isApproveLoginRequestsEnabled: Boolean, val isUnlockWithBiometricsEnabled: Boolean, val isUnlockWithPinEnabled: Boolean, @@ -179,6 +185,12 @@ sealed class AccountSecurityDialog : Parcelable { @Parcelize data object ConfirmLogout : AccountSecurityDialog() + /** + * Allows the user to view their fingerprint phrase. + */ + @Parcelize + data object FingerprintPhrase : AccountSecurityDialog() + /** * Allows the user to select a session timeout action. */ @@ -203,6 +215,11 @@ sealed class AccountSecurityEvent { */ data object NavigateBack : AccountSecurityEvent() + /** + * Navigate to fingerprint phrase information. + */ + data object NavigateToFingerprintPhrase : AccountSecurityEvent() + /** * Displays a toast with the given [Text]. */ @@ -246,6 +263,11 @@ sealed class AccountSecurityAction { */ data object DismissDialog : AccountSecurityAction() + /** + * User clicked fingerprint phrase. + */ + data object FingerPrintLearnMoreClick : AccountSecurityAction() + /** * User clicked lock now. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt index c5b2aeb99a..545fb28b1d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt @@ -61,7 +61,10 @@ fun BitwardenTheme( lightNonMaterialColors(context) } - CompositionLocalProvider(LocalNonMaterialColors provides nonMaterialColors) { + CompositionLocalProvider( + LocalNonMaterialColors provides nonMaterialColors, + LocalNonMaterialTypography provides nonMaterialTypography, + ) { // Set overall theme based on color scheme and typography settings MaterialTheme( colorScheme = colorScheme, @@ -147,6 +150,12 @@ private fun lightColorScheme(context: Context): ColorScheme = private fun Int.toColor(context: Context): Color = Color(context.getColor(this)) +/** + * Provides access to non material theme typography throughout the app. + */ +val LocalNonMaterialTypography: ProvidableCompositionLocal = + compositionLocalOf { nonMaterialTypography } + /** * Provides access to non material theme colors throughout the app. */ @@ -154,25 +163,28 @@ val LocalNonMaterialColors: ProvidableCompositionLocal = compositionLocalOf { // Default value here will immediately be overridden in BitwardenTheme, similar // to how MaterialTheme works. - NonMaterialColors(Color.Transparent, Color.Transparent) + NonMaterialColors(Color.Transparent, Color.Transparent, Color.Transparent) } /** * Models colors that live outside of the Material Theme spec. */ data class NonMaterialColors( + val fingerprint: Color, val passwordWeak: Color, val passwordStrong: Color, ) private fun lightNonMaterialColors(context: Context): NonMaterialColors = NonMaterialColors( + fingerprint = R.color.light_fingerprint.toColor(context), passwordWeak = R.color.light_password_strength_weak.toColor(context), passwordStrong = R.color.light_password_strength_strong.toColor(context), ) private fun darkNonMaterialColors(context: Context): NonMaterialColors = NonMaterialColors( + fingerprint = R.color.dark_fingerprint.toColor(context), passwordWeak = R.color.dark_password_strength_weak.toColor(context), passwordStrong = R.color.dark_password_strength_strong.toColor(context), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/Type.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/Type.kt index de3654f5ff..cb8fb6a6e6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/Type.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/Type.kt @@ -192,3 +192,25 @@ val Typography: Typography = Typography( platformStyle = PlatformTextStyle(includeFontPadding = false), ), ) + +val nonMaterialTypography: NonMaterialTypography = NonMaterialTypography( + fingerprint = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = FontFamily(Font(R.font.roboto_regular_mono)), + fontWeight = FontWeight.W400, + letterSpacing = 0.5.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), + ), +) + +/** + * Models typography that live outside of the Material Theme spec. + */ +data class NonMaterialTypography( + val fingerprint: TextStyle, +) diff --git a/app/src/main/res/font/roboto_regular_mono.ttf b/app/src/main/res/font/roboto_regular_mono.ttf new file mode 100644 index 0000000000..6df2b25360 Binary files /dev/null and b/app/src/main/res/font/roboto_regular_mono.ttf differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d17df32b69..d5fdcbe1c6 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -104,6 +104,8 @@ @color/grey_738182 @color/grey_EFEFF4 @color/white_FFFFFF + @color/magenta_C01176 + @color/magenta_F08DC7 @color/orange_B27400 @color/orange_C9914F @color/green_009A38 diff --git a/app/src/main/res/values/colors_palette.xml b/app/src/main/res/values/colors_palette.xml index c57bc669ba..7e22c129cf 100644 --- a/app/src/main/res/values/colors_palette.xml +++ b/app/src/main/res/values/colors_palette.xml @@ -66,5 +66,7 @@ #FFC9914F #FF009A38 #FF41B06D + #FFF08DC7 + #FFC01176 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 4ad15dbf7f..ce165a90be 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -14,6 +14,7 @@ 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 androidx.core.net.toUri import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -257,9 +258,60 @@ class AccountSecurityScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) } } + @Test + fun `fingerprint phrase dialog should be shown or hidden according to the state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) } + composeTestRule + .onNodeWithText("Fingerprint phrase") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Learn more") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Close") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("fingerprint-placeholder") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on close click should send DismissDialog`() { + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) } + composeTestRule + .onNodeWithText("Close") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) } + } + + @Test + fun `on learn more click should send FingerPrintLearnMoreClick`() { + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.FingerprintPhrase) } + composeTestRule + .onNodeWithText("Learn more") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.FingerPrintLearnMoreClick) } + } + + @Test + fun `on NavigateToFingerprintPhrase should call launchUri on intentHandler`() { + mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToFingerprintPhrase) + verify { + intentHandler.launchUri("http://bitwarden.com/help/fingerprint-phrase".toUri()) + } + } + companion object { private val DEFAULT_STATE = AccountSecurityState( dialog = null, + fingerprintPhrase = "fingerprint-placeholder".asText(), isApproveLoginRequestsEnabled = false, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index a8a3308cc5..3277e37fca 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -22,14 +22,21 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } @Test - fun `on AccountFingerprintPhraseClick should emit ShowToast`() = runTest { + fun `on AccountFingerprintPhraseClick should show the fingerprint phrase dialog`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick) + assertEquals( + DEFAULT_STATE.copy(dialog = AccountSecurityDialog.FingerprintPhrase), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on FingerPrintLearnMoreClick should emit NavigateToFingerprintPhrase`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { - viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick) - assertEquals( - AccountSecurityEvent.ShowToast("Display fingerprint phrase.".asText()), - awaitItem(), - ) + viewModel.trySendAction(AccountSecurityAction.FingerPrintLearnMoreClick) + assertEquals(AccountSecurityEvent.NavigateToFingerprintPhrase, awaitItem()) } } @@ -226,7 +233,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { private fun createViewModel( authRepository: AuthRepository = mockk(relaxed = true), - savedStateHandle: SavedStateHandle = SavedStateHandle(), + savedStateHandle: SavedStateHandle = SavedStateHandle().apply { + set("state", DEFAULT_STATE) + }, ): AccountSecurityViewModel = AccountSecurityViewModel( authRepository = authRepository, savedStateHandle = savedStateHandle, @@ -235,6 +244,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { companion object { private val DEFAULT_STATE = AccountSecurityState( dialog = null, + fingerprintPhrase = "fingerprint-placeholder".asText(), isApproveLoginRequestsEnabled = false, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = false,