feat(settings): ChangePasswordScreen + ViewModel with validation and typed error mapping
This commit is contained in:
parent
7fe095ef3b
commit
30000e7150
|
|
@ -0,0 +1,303 @@
|
||||||
|
package app.closer.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
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 androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import app.closer.domain.repository.ChangePasswordException
|
||||||
|
import app.closer.ui.components.CloserGlyphs
|
||||||
|
import app.closer.ui.components.CloserHeartLoader
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ChangePasswordUiState(
|
||||||
|
val currentPassword: String = "",
|
||||||
|
val newPassword: String = "",
|
||||||
|
val confirmPassword: String = "",
|
||||||
|
val passwordsVisible: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val done: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ChangePasswordViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(ChangePasswordUiState())
|
||||||
|
val uiState: StateFlow<ChangePasswordUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun updateCurrent(v: String) = _uiState.update { it.copy(currentPassword = v, error = null) }
|
||||||
|
fun updateNew(v: String) = _uiState.update { it.copy(newPassword = v, error = null) }
|
||||||
|
fun updateConfirm(v: String) = _uiState.update { it.copy(confirmPassword = v, error = null) }
|
||||||
|
fun toggleVisibility() = _uiState.update { it.copy(passwordsVisible = !it.passwordsVisible) }
|
||||||
|
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
val s = _uiState.value
|
||||||
|
if (s.isLoading) return
|
||||||
|
validate(s)?.let { message ->
|
||||||
|
_uiState.update { it.copy(error = message) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
authRepository.changePassword(s.currentPassword, s.newPassword)
|
||||||
|
.onSuccess {
|
||||||
|
// Don't keep the plaintext passwords around once we're done with them.
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
done = true,
|
||||||
|
currentPassword = "",
|
||||||
|
newPassword = "",
|
||||||
|
confirmPassword = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-side validation mirroring the sign-up policy; returns an error message or null if valid. */
|
||||||
|
private fun validate(s: ChangePasswordUiState): String? = when {
|
||||||
|
s.currentPassword.isBlank() -> "Enter your current password."
|
||||||
|
s.newPassword.length < 8 -> "New password must be at least 8 characters."
|
||||||
|
!s.newPassword.any { it.isLetter() } || !s.newPassword.any { it.isDigit() } ->
|
||||||
|
"New password must include both letters and numbers."
|
||||||
|
s.newPassword != s.confirmPassword -> "New passwords don't match."
|
||||||
|
s.newPassword == s.currentPassword -> "New password must be different from your current one."
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun friendlyError(e: Throwable): String = when (e) {
|
||||||
|
is ChangePasswordException.WrongCurrentPassword -> "Current password is incorrect."
|
||||||
|
is ChangePasswordException.WeakNewPassword ->
|
||||||
|
"That password is too weak — use at least 8 characters with letters and numbers."
|
||||||
|
is ChangePasswordException.TooManyAttempts -> "Too many attempts. Please wait a bit and try again."
|
||||||
|
is ChangePasswordException.ReauthRequired ->
|
||||||
|
"For your security, please sign out and back in, then change your password."
|
||||||
|
is ChangePasswordException.NoPassword ->
|
||||||
|
"This account signs in with Google — there's no password to change."
|
||||||
|
else -> e.message ?: "Couldn't change your password. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChangePasswordScreen(
|
||||||
|
onNavigate: (String) -> Unit = {},
|
||||||
|
viewModel: ChangePasswordViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
SettingsSubpage(title = "Change password", onBack = { onNavigate("back") }) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
if (state.done) {
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = SettingsPrimary.copy(alpha = 0.16f),
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
CloserGlyphs.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = SettingsPrimaryDeep,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Password updated",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = SettingsInk,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Your new password is set. Use it next time you sign in — this device stays signed in.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = SettingsMuted,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onNavigate("back") },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = SettingsPrimary,
|
||||||
|
contentColor = SettingsOnPrimary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Done", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Enter your current password, then choose a new one.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = SettingsMuted
|
||||||
|
)
|
||||||
|
|
||||||
|
val transform: VisualTransformation =
|
||||||
|
if (state.passwordsVisible) VisualTransformation.None else PasswordVisualTransformation()
|
||||||
|
|
||||||
|
val fieldColors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = SettingsPrimary,
|
||||||
|
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
|
||||||
|
cursorColor = SettingsPrimary,
|
||||||
|
focusedLabelColor = SettingsPrimary,
|
||||||
|
focusedTextColor = SettingsInk,
|
||||||
|
unfocusedTextColor = SettingsInk
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.currentPassword,
|
||||||
|
onValueChange = viewModel::updateCurrent,
|
||||||
|
label = { Text("Current password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = transform,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
isError = state.error != null,
|
||||||
|
colors = fieldColors,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.newPassword,
|
||||||
|
onValueChange = viewModel::updateNew,
|
||||||
|
label = { Text("New password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = transform,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
isError = state.error != null,
|
||||||
|
colors = fieldColors,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.confirmPassword,
|
||||||
|
onValueChange = viewModel::updateConfirm,
|
||||||
|
label = { Text("Confirm new password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = transform,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
isError = state.error != null,
|
||||||
|
colors = fieldColors,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "At least 8 characters, with letters and numbers.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = SettingsMuted,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
TextButton(onClick = viewModel::toggleVisibility) {
|
||||||
|
Text(
|
||||||
|
if (state.passwordsVisible) "Hide" else "Show",
|
||||||
|
color = SettingsPrimaryDeep
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { err ->
|
||||||
|
Text(
|
||||||
|
text = err,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = SettingsDanger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::submit,
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = SettingsPrimary,
|
||||||
|
contentColor = SettingsOnPrimary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CloserHeartLoader(size = 20.dp)
|
||||||
|
Text("Updating…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Update password", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ChangePasswordScreenPreview() {
|
||||||
|
ChangePasswordScreen()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue