mirror of
https://github.com/bitwarden/android.git
synced 2026-08-02 02:32:04 -05:00
BIT-707 Implement password strength indicator with mock values (#161)
This commit is contained in:
committed by
Álison Fernandes
parent
4ce7e0842b
commit
343e17f1f4
@@ -1,5 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
@@ -57,4 +58,9 @@ interface AuthRepository {
|
||||
* Set the value of [captchaTokenResultFlow].
|
||||
*/
|
||||
fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult)
|
||||
|
||||
/**
|
||||
* Get the password strength for the given [email] and [password] combo.
|
||||
*/
|
||||
suspend fun getPasswordStrength(email: String, password: String): Result<PasswordStrength>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
@@ -18,6 +19,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
|
||||
import com.x8bit.bitwarden.data.auth.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import com.x8bit.bitwarden.data.platform.util.flatMap
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -225,4 +227,22 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
|
||||
mutableCaptchaTokenFlow.tryEmit(tokenResult)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
override suspend fun getPasswordStrength(
|
||||
email: String,
|
||||
password: String,
|
||||
): Result<PasswordStrength> {
|
||||
// TODO: Replace with SDK call (BIT-964)
|
||||
// Ex: return authSdkSource.passwordStrength(email, password)
|
||||
val length = password.length
|
||||
return when {
|
||||
length <= 3 -> PasswordStrength.LEVEL_0
|
||||
length <= 6 -> PasswordStrength.LEVEL_1
|
||||
length <= 9 -> PasswordStrength.LEVEL_2
|
||||
length <= 11 -> PasswordStrength.LEVEL_3
|
||||
else -> PasswordStrength.LEVEL_4
|
||||
}
|
||||
.asSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -215,7 +215,19 @@ fun CreateAccountScreen(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = state.passwordLengthLabel(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
PasswordStrengthIndicator(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
state = state.passwordStrengthState,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BitwardenPasswordField(
|
||||
label = stringResource(id = R.string.retype_master_password),
|
||||
value = state.confirmPasswordInput,
|
||||
|
||||
+60
-1
@@ -5,6 +5,7 @@ import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
@@ -14,16 +15,19 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Che
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ContinueWithBreachedPasswordClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.EmailInputChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Internal.ReceivePasswordStrengthResult
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordHintChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PasswordInputChange
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.PrivacyPolicyClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.SubmitClick
|
||||
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.TermsClick
|
||||
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 com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
|
||||
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -52,9 +56,16 @@ class CreateAccountViewModel @Inject constructor(
|
||||
isAcceptPoliciesToggled = false,
|
||||
isCheckDataBreachesToggled = true,
|
||||
dialog = null,
|
||||
passwordStrengthState = PasswordStrengthState.NONE,
|
||||
),
|
||||
) {
|
||||
|
||||
/**
|
||||
* Keeps track of async request to get password strength. Should be cancelled
|
||||
* when user input changes.
|
||||
*/
|
||||
private var passwordStrengthJob: Job = Job().apply { complete() }
|
||||
|
||||
init {
|
||||
// As state updates, write to saved state handle:
|
||||
stateFlow
|
||||
@@ -94,6 +105,24 @@ class CreateAccountViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
|
||||
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) {
|
||||
action.result.onSuccess {
|
||||
val updatedState = when (it) {
|
||||
PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1
|
||||
PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2
|
||||
PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3
|
||||
PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD
|
||||
PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG
|
||||
}
|
||||
mutableStateFlow.update { oldState ->
|
||||
oldState.copy(
|
||||
passwordStrengthState = updatedState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +232,23 @@ class CreateAccountViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun handlePasswordInputChanged(action: PasswordInputChange) {
|
||||
// Update input:
|
||||
mutableStateFlow.update { it.copy(passwordInput = action.input) }
|
||||
// Update password strength:
|
||||
passwordStrengthJob.cancel()
|
||||
if (action.input.isEmpty()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(passwordStrengthState = PasswordStrengthState.NONE)
|
||||
}
|
||||
} else {
|
||||
passwordStrengthJob = viewModelScope.launch {
|
||||
val result = authRepository.getPasswordStrength(
|
||||
email = mutableStateFlow.value.emailInput,
|
||||
password = action.input,
|
||||
)
|
||||
trySendAction(ReceivePasswordStrengthResult(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) {
|
||||
@@ -300,7 +345,14 @@ data class CreateAccountState(
|
||||
val isCheckDataBreachesToggled: Boolean,
|
||||
val isAcceptPoliciesToggled: Boolean,
|
||||
val dialog: CreateAccountDialog?,
|
||||
) : Parcelable
|
||||
val passwordStrengthState: PasswordStrengthState,
|
||||
) : Parcelable {
|
||||
|
||||
val passwordLengthLabel: Text
|
||||
get() =
|
||||
R.string.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum
|
||||
.asText(MIN_PASSWORD_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Models dialogs that can be displayed on the create account screen.
|
||||
@@ -445,5 +497,12 @@ sealed class CreateAccountAction {
|
||||
data class ReceiveRegisterResult(
|
||||
val registerResult: RegisterResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a password strength result has been received.
|
||||
*/
|
||||
data class ReceivePasswordStrengthResult(
|
||||
val result: Result<PasswordStrength>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.x8bit.bitwarden.ui.auth.feature.createaccount
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
|
||||
|
||||
/**
|
||||
* Draws a password indicator that displays password strength based on the given [state].
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun PasswordStrengthIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
state: PasswordStrengthState,
|
||||
) {
|
||||
val widthPercent by animateFloatAsState(
|
||||
targetValue = when (state) {
|
||||
PasswordStrengthState.NONE -> 0f
|
||||
PasswordStrengthState.WEAK_1 -> .25f
|
||||
PasswordStrengthState.WEAK_2 -> .5f
|
||||
PasswordStrengthState.WEAK_3 -> .66f
|
||||
PasswordStrengthState.GOOD -> .82f
|
||||
PasswordStrengthState.STRONG -> 1f
|
||||
},
|
||||
label = "Width Percent State",
|
||||
)
|
||||
val indicatorColor = when (state) {
|
||||
PasswordStrengthState.NONE -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_1 -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_2 -> MaterialTheme.colorScheme.error
|
||||
PasswordStrengthState.WEAK_3 -> LocalNonMaterialColors.current.passwordWeak
|
||||
PasswordStrengthState.GOOD -> MaterialTheme.colorScheme.primary
|
||||
PasswordStrengthState.STRONG -> LocalNonMaterialColors.current.passwordStrong
|
||||
}
|
||||
val animatedIndicatorColor by animateColorAsState(
|
||||
targetValue = indicatorColor,
|
||||
label = "Indicator Color State",
|
||||
)
|
||||
val label = when (state) {
|
||||
PasswordStrengthState.NONE -> "".asText()
|
||||
PasswordStrengthState.WEAK_1 -> R.string.weak.asText()
|
||||
PasswordStrengthState.WEAK_2 -> R.string.weak.asText()
|
||||
PasswordStrengthState.WEAK_3 -> R.string.weak.asText()
|
||||
PasswordStrengthState.GOOD -> R.string.good.asText()
|
||||
PasswordStrengthState.STRONG -> R.string.strong.asText()
|
||||
}
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
transformOrigin = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 0f)
|
||||
scaleX = widthPercent
|
||||
}
|
||||
.drawBehind {
|
||||
drawRect(animatedIndicatorColor)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = indicatorColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models various levels of password strength that can be displayed by [PasswordStrengthIndicator].
|
||||
*/
|
||||
enum class PasswordStrengthState {
|
||||
NONE,
|
||||
WEAK_1,
|
||||
WEAK_2,
|
||||
WEAK_3,
|
||||
GOOD,
|
||||
STRONG,
|
||||
}
|
||||
+47
-6
@@ -12,7 +12,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
@@ -52,12 +55,20 @@ fun BitwardenTheme(
|
||||
}
|
||||
}
|
||||
|
||||
// Set overall theme based on color scheme and typography settings
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
val nonMaterialColors = if (darkTheme) {
|
||||
darkNonMaterialColors(context)
|
||||
} else {
|
||||
lightNonMaterialColors(context)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalNonMaterialColors provides nonMaterialColors) {
|
||||
// Set overall theme based on color scheme and typography settings
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun darkColorScheme(context: Context): ColorScheme =
|
||||
@@ -135,3 +146,33 @@ private fun lightColorScheme(context: Context): ColorScheme =
|
||||
@ColorRes
|
||||
private fun Int.toColor(context: Context): Color =
|
||||
Color(context.getColor(this))
|
||||
|
||||
/**
|
||||
* Provides access to non material theme colors throughout the app.
|
||||
*/
|
||||
val LocalNonMaterialColors: ProvidableCompositionLocal<NonMaterialColors> =
|
||||
compositionLocalOf {
|
||||
// Default value here will immediately be overridden in BitwardenTheme, similar
|
||||
// to how MaterialTheme works.
|
||||
NonMaterialColors(Color.Transparent, Color.Transparent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Models colors that live outside of the Material Theme spec.
|
||||
*/
|
||||
data class NonMaterialColors(
|
||||
val passwordWeak: Color,
|
||||
val passwordStrong: Color,
|
||||
)
|
||||
|
||||
private fun lightNonMaterialColors(context: Context): NonMaterialColors =
|
||||
NonMaterialColors(
|
||||
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(
|
||||
passwordWeak = R.color.dark_password_strength_weak.toColor(context),
|
||||
passwordStrong = R.color.dark_password_strength_strong.toColor(context),
|
||||
)
|
||||
Reference in New Issue
Block a user